# Pinner.xyz
Privacy-focused storage without the infrastructure.
Store anything. Decentralized, private, no lock-in.
Get Started
CLI
## Products
| | | |
|---|---|---|
| **IPFS Pinning** | Pin content to IPFS with reliable, transparent pricing. | [Get started →](/ipfs/quickstart) |
| **Websites & Domains** | Deploy static sites, SPAs, and dapps on decentralized storage with your own domain. | [Get started →](/hosting/quickstart) |
## What is Pinner?
**Pinner is:** a decentralized storage service that pins your content and respects your privacy. Your data lives on a distributed network; no single point of failure, no vendor lock-in. One subscription covers pinning, website hosting, and DNS.
**Pinner is not:** a public anonymous IPFS gateway or a lock-in platform. We don't serve arbitrary IPFS content to the public; see [Why No Public Gateway](/ipfs/concepts/why-no-gateway) for the reasoning. Website hosting serves your pinned content through ipfs.pub.
***
**Migrating from Pinata?** See the [Pinata migration guide](/migration/pinata) for a side-by-side comparison.
**Fleek refugee?** See the [Fleek migration guide](/migration/fleek) to move your DNS and hosted sites to Pinner.
# Sign Up & Get Started
Get from zero to pinning in three steps.
## Step 1: Create an account
Go to [pinner.xyz](https://pinner.xyz) and sign up, or register directly from the CLI (see below). You'll need an email address, name, and password. Email verification is required before you can log in.
After signing up, choose a subscription plan in the dashboard. Plans support card payments (via Stripe) and crypto payments (via ATLOS). No ID is required for crypto payments.
## Step 2: Install the CLI and authenticate
:::code-group
```bash [macOS / Linux]
curl -fsSL https://get.pinner.xyz | sh
```
```powershell [Windows]
irm https://get.pinner.xyz/install.ps1 | iex
```
:::
Then log in; the CLI will walk you through authentication and automatically create an API key for your machine:
```bash
pinner auth --email you@example.com
```
Or use the interactive setup wizard for a guided first-time experience:
```bash
pinner setup
```
If you already have an auth token from another machine, you can save it directly:
```bash
pinner auth YOUR_AUTH_TOKEN
```
## Step 3: Start pinning
```bash
# Upload a file
pinner upload ./my-file.txt
# Pin by CID
pinner pin QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco
```
That's it. You're pinning.
***
### Register from the CLI
You can also create a new account entirely from the CLI:
```bash
# Interactive (prompts for all fields)
pinner register
# Non-interactive
pinner register --email you@example.com --first-name Jane --last-name Doe --password yourpassword
```
Then confirm your email address:
```bash
pinner confirm-email --email you@example.com --token abc123def456
```
# API Keys
## API key vs auth token
Pinner uses two types of credentials. It's important to understand the difference:
| | API key | Auth token |
|--|---------|------------|
| **What it is** | A long-lived JWT (30-day expiry) with audience `api`, created via `pinner auth` or `pinner account api-keys` | A short-lived session JWT with audience `login`, issued during authentication |
| **How you use it** | Store it in the `PINNER_AUTH_TOKEN` env var, or pass it as a Bearer token to the API | Not typically used directly; managed internally by the CLI and browser sessions |
| **Lifetime** | 30 days from creation (can also have an optional expiry on the key record) | Expires after a session timeout |
| **Where it lives** | `PINNER_AUTH_TOKEN` env var, CLI config (`auth_token`), or `Authorization: Bearer` header | Internal to CLI sessions and browser cookies |
As a developer, you interact with **API keys**. The term "auth token" refers to the internal session JWT; you rarely need to handle it directly.
## Primary path: CLI
Run `pinner auth` to log in and create an API key. The CLI authenticates you, generates a key automatically, and saves the resulting JWT to your local config. You don't need to visit the dashboard to get a key.
```bash
# Interactive: prompts for email and password
pinner auth
# Semi-interactive: provide email, prompted for password (and OTP if 2FA is enabled)
pinner auth --email you@example.com
# Name your key (useful for tracking which machine it's on)
pinner auth --email you@example.com --key-name "ci-pipeline"
# Skip API key creation, save the login token directly
pinner auth --email you@example.com --no-create-key
```
The default key name is `cli-generated` when `--key-name` is not specified. If a key with the same name already exists, it is replaced automatically.
### Non-interactive (CI/CD)
For automated environments, pass credentials via environment variables:
```bash
PINNER_EMAIL=you@example.com PINNER_PASSWORD=yourpassword pinner auth
```
With 2FA enabled:
```bash
PINNER_EMAIL=you@example.com PINNER_PASSWORD=yourpassword PINNER_OTP=123456 pinner auth
```
Or provide an existing JWT token directly:
```bash
pinner auth
```
This is the CI/CD path; prefer the interactive `pinner auth` for local development.
## Managing API keys via CLI
The `pinner account api-keys` command provides full lifecycle management of API keys:
```bash
# List all API keys
pinner account api-keys list
# Search keys by name
pinner account api-keys list --search my-key
# Create a new API key (token is shown once; save it securely)
pinner account api-keys create my-key
# Delete an API key by UUID or name
pinner account api-keys delete my-key
# Force-delete the key currently used for authentication
pinner account api-keys delete my-key --force
```
The token returned by `create` can be used with `pinner auth --auth-token ` or the `PINNER_AUTH_TOKEN` environment variable.
## API endpoints
For programmatic access, API keys can be managed via REST endpoints:
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/account/keys` | Create an API key (body: `{"name": "my-key"}`) |
| `GET` | `/api/account/keys` | List API keys (paginated, with filters and sort) |
| `DELETE` | `/api/account/keys/:keyID` | Delete an API key by UUID |
All endpoints require an `Authorization: Bearer` header with a valid login JWT.
## Exchanging an API key for a login token
API key JWTs have the `api` audience. Some API endpoints require a `login` audience JWT. To exchange an API key for a login JWT:
```
POST /api/auth/key
Authorization: APIKey
```
The response returns a `{"token": ""}`. The CLI handles this exchange automatically when needed.
## Fallback: Dashboard manual generation
If you can't use the CLI, you can generate an API key in the web dashboard. Go to your account settings and create a new API key.
This is the fallback path; prefer `pinner auth` or `pinner account api-keys` instead.
## Environment variables
Set your credentials as environment variables so the CLI picks them up automatically:
| Environment variable | Purpose | Used by |
|----------------------|---------|---------|
| `PINNER_AUTH_TOKEN` | Your API key JWT or login token for authentication | SDK, CLI |
| `PINNER_EMAIL` | Email address for login | CLI (`pinner auth`) |
| `PINNER_PASSWORD` | Password for login (prefer stdin or env var) | CLI (`pinner auth`) |
| `PINNER_OTP` | OTP code for 2FA login | CLI (`pinner auth`) |
```bash
# Most common: set your API key token
export PINNER_AUTH_TOKEN="your-api-key-jwt"
# For login flows
export PINNER_EMAIL="you@example.com"
export PINNER_PASSWORD="yourpassword"
```
The CLI checks these env vars automatically when you don't pass a token directly. The SDK requires you to pass the JWT explicitly; it does not read env vars.
## Authentication method
API keys are JWTs signed with Ed25519. When calling the API:
* **Authorization header**: `Authorization: Bearer` is the primary method
* **Cookie**: The auth middleware also checks the configured auth cookie
* **Query parameter**: As a fallback, the token can be passed as a query parameter
The SDK uses the `Authorization: Bearer` header by default.
## Scopes
All API keys currently grant full account access. Scoped keys are on the roadmap.
## Rotation
To rotate an API key:
1. Create a new key with `pinner account api-keys create rotation-$(date +%Y%m%d)`
2. Update your environment variables and deployments with the new token
3. Delete the old key: `pinner account api-keys delete `
Or use `pinner auth` with `--key-name`; it automatically replaces any existing key with the same name.
## Revocation
Revoke a compromised or unused key through any of these methods:
* **CLI**: `pinner account api-keys delete `
* **API**: `DELETE /api/account/keys/:keyID`
* **Dashboard**: Go to your account settings and delete the key
If you delete the key currently used for authentication, you must re-authenticate with `pinner auth`.
## 2FA / OTP management
Secure your account with one-time passwords.
```bash
# Enable 2FA (generates a secret, prompts for OTP verification)
pinner account otp enable
# Enable 2FA non-interactively (provide the OTP code from your authenticator app)
pinner account otp enable --otp 123456
# Disable 2FA (prompts for password)
pinner account otp disable
# Disable 2FA non-interactively
pinner account otp disable --password mypassword
```
When 2FA is active, include your OTP code when authenticating:
```bash
PINNER_EMAIL=you@example.com PINNER_PASSWORD=yourpassword PINNER_OTP=123456 pinner auth
```
# Billing & Pricing
## Plans
Pinner offers subscription plans with different storage, upload, and download allowances. Visit [pinner.xyz](https://pinner.xyz) for current pricing and plan details.
Each plan renews automatically at the cadence you chose (monthly or yearly). Yearly plans are discounted.
## Payment methods
Pinner accepts card and cryptocurrency; no identity verification required for crypto payments.
| Currency | Type | Notes |
|----------|------|-------|
| **BTC** | Bitcoin | Broad support, widely available |
| **ETH** | Ethereum | Fast confirmations on mainnet |
| **SOL** | Solana | Low fees, fast finality |
| **LTC** | Litecoin | Low fees, fast settlement |
| **XMR** | Monero | Private by default; transactions are untraceable |
| **DOGE** | Dogecoin | Low-cost transactions |
| **BNB** | BNB | Binance ecosystem |
| **POL** | Polygon | Ethereum L2 scaling |
| **USDC** | Stablecoin | USD-pegged, price stability |
| **USDT** | Stablecoin | USD-pegged, widely supported |
| **DAI** | Stablecoin | USD-pegged, decentralized |
| **PYUSD** | Stablecoin | USD-pegged, backed by PayPal |
:::tip
**Privacy-first payments.** Pinner is one of the few storage providers that accepts Monero (XMR). If privacy matters to you, XMR transactions are untraceable by design; no sender, receiver, or amount is visible on-chain.
:::
## Your balance
Your account has a credit balance. Payments add credits; subscription renewals and overage usage deduct them. You can view your current balance and transaction history in the dashboard.
## Changing plans
Switch plans at any time. You'll get a credit for the unused portion of your current plan, and the new plan is charged for the remaining period. No double-billing.
For card payments, plan changes are handled through the Stripe customer portal (linked from the dashboard).
## Cancelling
You can cancel from the dashboard. Cancellation takes effect at the end of your current billing period; you keep access until then. You can also undo a scheduled cancellation before it takes effect.
## What happens to my content if I cancel?
When you cancel, your pins stay active until the end of the billing period. After that, Pinner stops pinning your content.
What happens next depends on whether anyone else is holding your data:
* **If your content is pinned elsewhere** by your own IPFS node, another pinning service, or other network peers, it stays available on IPFS. Your CIDs are portable: pin them on another provider with no re-upload.
* **If Pinner is the only node holding your content**, it becomes unreachable once Pinner stops pinning it, until you re-pin it somewhere else.
This is the reality of any single pinning provider, not just Pinner. We recommend [exporting your pin list](/ipfs/how-to/list-pins) before cancelling, so you know exactly what to re-pin. If availability matters for your production content, consider pinning critical data with at least two providers.
Because IPFS is content-addressed and open, there's no lock-in; migration is a pin list export and re-import, not a data migration.
## Support
Email [support@pinner.xyz](mailto:support@pinner.xyz) for billing questions.
# Quotas & Limits
Every Pinner account has quotas that govern how much you can upload, download, and store. These keep the shared infrastructure fair for everyone.
## Quota types
| Type | What it limits |
|------|---------------|
| **Upload** | Bytes you can upload per month |
| **Download** | Bytes you can download per month |
| **Storage** | Total bytes of pinned content you can store |
Limits are evaluated over a monthly window by default. Check the dashboard or API for your current plan's exact limits and usage.
## When you hit a limit
If an upload would push you past your storage or upload quota, the request is rejected with an error like `storage quota exceeded` or `upload quota exceeded`. The response includes your current usage, the requested bytes, and your limit so you can see how much headroom remains.
## Checking your usage
Use the dashboard to see your current usage and remaining quota. You can also query the API for per-type byte counts and effective limits.
## Need more?
Email [support@pinner.xyz](mailto:support@pinner.xyz) to discuss increased allowances or custom plans.
# Operations
When you upload a file, pin content, or trigger other server-side processing, Pinner creates an operation to track it. Operations let you monitor progress, check whether a request completed, and inspect errors if something went wrong.
## List operations
```bash
# List your account operations
pinner operations list
# Filter by status
pinner operations list --status running
# Filter by CID
pinner operations list --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
# Continuously watch operations
pinner operations list --watch
```
## Get a specific operation
```bash
# Get details for one operation
pinner operations get
# Watch an operation until it completes
pinner operations get --watch
```
## What you'll see
Operations track state across uploads, pins, website deployments, and other server-side processing. Each operation has:
* **ID**: unique identifier
* **Status**: `pending`, `running`, `completed`, `failed`, or `error`
* **CID**: the content identifier, when applicable
* **Progress**: percentage complete
When you run `pinner operations get`, additional details are shown including the updated timestamp, step progress (if multi-step), and any error message.
Use `--watch` to continuously monitor operations instead of polling manually. The command refreshes every 2 seconds and exits automatically when all operations reach a terminal status (`completed`, `failed`, or `error`).
# Manage DNS Zones
DNS zones are required for custom domains on Website Hosting. Before you can add records, you need a zone for your domain. Each zone starts in `pending_nameserver` status and moves to `active` once its nameservers are verified.
## List zones
```bash
pinner dns zones list
```
Returns all zones in your account with their ID, domain name, and status. The possible statuses are:
* `pending_nameserver`: the zone's nameservers have not yet been verified
* `active`: nameserver delegation has been verified
## Create a zone
```bash
# Create a zone for your domain
pinner dns zones create --domain example.com
# Optionally specify custom nameservers
pinner dns zones create --domain example.com --nameservers ns1.example.com,ns2.example.com
```
This creates the zone in `pending_nameserver` status. Update your domain registrar to point to the Pinner name servers, then validate the zone (see below).
## Get zone details
```bash
# By domain name
pinner dns zones get example.com
# By numeric zone ID
pinner dns zones get 42
```
Displays the zone's ID, domain, status, and timestamps.
## Validate zone delegation
Check whether a zone's nameservers are properly delegated:
```bash
pinner dns zones validate example.com
```
Returns a validation result showing whether delegation is valid, a descriptive message, and (if validation fails) the Pinner name servers you should point to. Re-run after updating your registrar until the result shows valid.
## Delete a zone
```bash
# By domain name
pinner dns zones delete example.com
# By numeric zone ID
pinner dns zones delete 42
```
:::warning
Deleting a zone removes all records in it. This breaks any custom domains that depend on those records.
:::
## Typical workflow
1. Create a zone for your domain: `pinner dns zones create --domain example.com`
2. Validate the zone: `pinner dns zones validate example.com`
3. Point your domain's NS records to the Pinner name servers shown in the validation output
4. Re-validate until the zone status becomes `active`
5. Add records to the zone (see [Manage DNS Records](/dns/how-to/manage-records))
6. Use the domain with Website Hosting
# Manage DNS Records
DNS records link your domain to Pinner services. Common uses: TXT records for domain validation, dnslink TXT records for IPFS content addressing, and ALIAS records for Website Hosting.
## Supported record types
The following DNS record types can be created through the API and CLI:
| Type | Use case |
|------|----------|
| A | Map a name to an IPv4 address |
| AAAA | Map a name to an IPv6 address |
| ALIAS | Map the zone apex to another domain (used by Website Hosting) |
| CNAME | Map a subdomain to another domain |
| MX | Specify mail servers |
| NS | Delegate a subdomain to another set of nameservers |
| SRV | Specify services by port and hostname |
| TXT | Store arbitrary text (validation tokens, dnslink records, SPF, etc.) |
SOA and NS records for the zone apex are managed automatically by PowerDNS and cannot be modified directly.
## List records
```bash
# List all records in a zone
pinner dns records list example.com
```
## Create a record
### TXT record (domain validation)
```bash
pinner dns records create example.com \
--name "pinner-verify" \
--type TXT \
--content "pinner-verify=abc123def456"
```
### TXT record (dnslink for IPFS content)
```bash
pinner dns records create example.com \
--name "_dnslink" \
--type TXT \
--content "dnslink=/ipfs/bafybeig...cid"
```
dnslink TXT values must follow the `dnslink=/ipfs/` or `dnslink=/ipns/` format per the [dnslink spec](https://dnslink.info/).
### CNAME record (subdomain alias)
```bash
pinner dns records create example.com \
--type CNAME \
--name "www" \
--content "example.org"
```
### ALIAS record (zone apex)
```bash
pinner dns records create example.com \
--type ALIAS \
--name "@" \
--content "gateway.pinner.xyz."
```
ALIAS records are used at the zone apex (`@`) where CNAME is not allowed. Website Hosting automatically creates ALIAS records pointing to the gateway domain.
### A record
```bash
pinner dns records create example.com \
--type A \
--name "@" \
--content "203.0.113.42"
```
### AAAA record
```bash
pinner dns records create example.com \
--type AAAA \
--name "@" \
--content "2001:db8::1"
```
### With optional flags
```bash
# Set a custom TTL (default: 3600 seconds)
pinner dns records create example.com \
--name "www" \
--type CNAME \
--content "example.org" \
--ttl 7200
# Create a disabled record
pinner dns records create example.com \
--name "staging" \
--type CNAME \
--content "staging.example.org" \
--disabled
```
## Get a record
```bash
pinner dns records get example.com --name www --type CNAME
```
## Update a record
```bash
pinner dns records update example.com \
--name www \
--type CNAME \
--content "new-site.example.com"
```
You can also update the TTL or disabled state:
```bash
pinner dns records update example.com \
--name www \
--type CNAME \
--content "new-site.example.com" \
--ttl 7200
```
## Delete a record
```bash
pinner dns records delete example.com \
--name www \
--type CNAME
```
List records with `pinner dns records list example.com` to find the names and types of your records.
## Common patterns
| Goal | Record type | Name | Content |
|------|------------|------|---------|
| Prove domain ownership | TXT | `pinner-verify` | `pinner-verify=` |
| dnslink (IPFS content mapping) | TXT | `_dnslink` | `dnslink=/ipfs/` |
| dnslink (IPNS content mapping) | TXT | `_dnslink` | `dnslink=/ipns/` |
| Website Hosting (auto-created) | ALIAS | `@` | Gateway domain |
| Subdomain alias | CNAME | `www` | Target domain |
| Root domain pointing | A | `@` | IPv4 address |
| IPv6 root domain | AAAA | `@` | IPv6 address |
# Migrate from Fleek
Fleek Hosting shut down on January 31, 2026. If you had a site on Fleek, this guide walks you through getting it back up on Pinner.
## The short version
If you already have your build output and just need to get it live:
```bash
# 1. Authenticate
pinner auth
# 2. Launch the wizard: handles upload, website creation, and DNS in one flow
pinner websites wizard
# Done: your site is live on IPFS
```
## What you had on Fleek vs. what Pinner provides
Fleek Hosting was an all-in-one platform. It combined CDN, IPFS pinning, DNS, SSL, ENS, IPNS, and CI into a single service. Pinner takes a different approach: it handles IPFS pinning, website hosting, DNS, and SSL, but each concern stays independently manageable. If Pinner ever stops serving your needs, your CIDs and your content move with you.
| Fleek feature | Pinner equivalent | Notes |
|---------------|-------------------|-------|
| HTTP CDN with TLS | Pinner gateway + automatic SSL | Your site serves over HTTPS at your domain |
| IPFS pinning | Pinner IPFS pinning | Same CIDs: your content identifiers don't change |
| IPFS gateway | `ipfs.pub` | No public anonymous gateway; see [Why no public gateway](/ipfs/concepts/why-no-gateway) |
| DNSLink | Pinner DNS hosting | Pinner manages DNSLink TXT records automatically |
| IPNS | Pinner IPNS | `pinner ipns publish` or SDK equivalent |
| ENS | `pinner point` | Maps an onchain name to a CID via IPNS. See [Onchain Names](/onchain/quickstart) |
| GitHub Actions CI | `pinner-deploy-action` | Drop-in replacement: see [Deploy with GitHub Actions](/hosting/how-to/deploy-github-actions) |
| Dashboard | Pinner web portal | Manage plans, billing, and websites at pinner.xyz |
| Domain registration | Bring your own domain | Pinner doesn't sell domains: you register at your registrar and point DNS at Pinner |
## Quick path
The fastest way to get your site back up if you have your build output ready.
### 1. Install and authenticate
```bash
npm install -g @lumeweb/pinner
pinner auth
```
`pinner auth` prompts for your email and password interactively. For CI/CD, pass your API key directly: `pinner auth YOUR_API_KEY`.
### 2. Deploy with the wizard
The interactive wizard handles upload, website creation, and DNS setup in one guided flow: closest to the one-click experience you had on Fleek:
```bash
# Build your site if you haven't already
npm run build
# Launch the wizard
pinner websites wizard
```
The wizard walks you through:
1. **Content source**: select the directory to upload (e.g. `./dist`)
2. **Target type**: IPFS or IPNS
3. **Domain**: your custom domain or a `*.pinner.xyz` subdomain
4. **DNS mode**: Pinner-managed (recommended) or self-managed
5. **Validation**: verifies DNS records are in place
That's it. Your site is live on IPFS.
Manual alternative: if you prefer running commands individually or are scripting the migration
```bash
# Upload the build output to IPFS
pinner upload ./dist
```
Note the CID from the output. Then create the website:
```bash
# For a subdomain (fastest: no DNS setup needed)
pinner websites create mysite.pinner.xyz --cid
# For a custom domain
pinner websites create mysite.example.com --cid
```
If you used a custom domain, Pinner needs to verify you own it. With Pinner-managed DNS (recommended): delegate your domain's nameservers to Pinner, and it handles DNSLink, validation, and ALIAS records automatically. For full DNS setup instructions, see [Migrate DNS from Fleek](/migration/fleek/dns-migration).
Wait for SSL:
```bash
pinner websites ssl status mysite.example.com
```
## Full path with CI
If you had a Fleek GitHub integration that auto-deployed on push, replace it with Pinner's GitHub Action. The workflow is similar: build, upload, deploy.
### Replace your Fleek workflow
Your Fleek workflow probably looked something like this:
```yaml
# Old Fleek workflow (no longer works)
- name: Deploy to Fleek
uses: fleek-platform/action@v1
with:
apiKey: ${{ secrets.FLEEK_API_KEY }}
siteId: ${{ secrets.FLEEK_SITE_ID }}
```
Replace it with:
```yaml
- name: Deploy to Pinner
uses: lumeweb/pinner-deploy-action@v0
with:
api-key: ${{ secrets.PINNER_API_KEY }}
path: ./dist
domain: mysite.example.com
```
Set the `PINNER_API_KEY` secret in your repository settings:
1. Go to **Settings → Secrets and variables → Actions**
2. Click **New repository secret**
3. Name: `PINNER_API_KEY`
4. Value: your API key from [API Keys](/account/api-tokens)
The action uploads your build output, pins it on IPFS, and creates or updates the website record all in one step.
For IPNS publishing, automatic cleanup, and pin-by-CID mode, see the full [GitHub Actions documentation](/hosting/how-to/deploy-github-actions).
## Feature differences to know about
A few things work differently on Pinner. Being upfront about these so you're not surprised.
### No public anonymous gateway
Fleek provided a public IPFS gateway. Pinner operates `ipfs.pub`, but it's not an anonymous, general-purpose gateway. For details on why and how to access IPFS content, see [Why no public gateway](/ipfs/concepts/why-no-gateway).
You can also use [check.ipfs.pub](https://check.ipfs.pub) (Pinner's deploy of check.ipfs.network) to verify your content is available on the IPFS network.
### DNS is separate from domain registration
On Fleek, you could register a domain and it was automatically configured. Pinner doesn't sell domains; you register at your preferred registrar and point DNS at Pinner's nameservers. If you already own your domain, this is a one-time setup.
### ENS: use `pinner point`
If you had an ENS name pointing at your Fleek site, use `pinner point` to map the ENS name to your new CID via IPNS:
```bash
pinner point myname.eth --cid
```
This creates an IPNS key and publishes the CID under it. See [Onchain Names](/onchain/quickstart) for the full flow.
### Your CIDs stay the same
This is important: the content identifier (CID) for your files doesn't change when you move providers. IPFS content addressing is provider-independent. If you have CIDs from Fleek, you can pin them directly on Pinner without re-uploading:
```bash
pinner pin add QmYourCIDHere
```
Or via the GitHub Action:
```yaml
- name: Pin existing CID
uses: lumeweb/pinner-deploy-action@v0
with:
api-key: ${{ secrets.PINNER_API_KEY }}
cid: QmYourCIDHere
```
## Self-hosting vs. Pinner
The IPFS Foundation published a [modular migration guide](https://blog.ipfs.tech/2026-fleek-migration/) that walks through self-hosting with Kubo + Caddy + Cloudflare DNS + GitHub Actions. That approach gives you maximum control but requires running your own IPFS node and managing infrastructure.
Pinner gives you the same modularity without the operational overhead. Your CIDs are yours, your content is portable, and you can switch providers at any time, but you don't have to maintain an IPFS Cluster or a reverse proxy. If self-hosting appeals to you, the Shipyard guide is excellent. If you'd rather focus on building your site than running infrastructure, Pinner handles the IPFS layer for you.
## Migration checklist
* \[ ] Install Pinner CLI (`npm install -g @lumeweb/pinner`)
* \[ ] Authenticate (`pinner auth`)
* \[ ] Upload your site or pin existing CIDs
* \[ ] Create the website record
* \[ ] Configure DNS (see [DNS migration](/migration/fleek/dns-migration) if using a custom domain)
* \[ ] Verify SSL provisioning
* \[ ] Replace Fleek GitHub Action with `pinner-deploy-action`
* \[ ] Set up `pinner point` if you had ENS names
* \[ ] Remove Fleek secrets from your repository
* \[ ] Monitor your site for 24-48 hours during DNS propagation
## Next steps
* [DNS Migration from Fleek](/migration/fleek/dns-migration): detailed DNS setup and troubleshooting
* [Deploy with GitHub Actions](/hosting/how-to/deploy-github-actions): full CI/CD setup
* [Custom Domains](/hosting/how-to/custom-domains): domain setup reference
* [Migrate from Pinata](/migration/pinata): migrating SDK calls if you were also using Pinata for pinning
# DNS Migration from Fleek
DNS is the most important part of migrating off Fleek. The good news: if you were on Fleek, you're already managing your own DNS at your registrar. You don't need to switch nameservers; you just need to change what your records point at.
## How Fleek handled DNS
On Fleek, you managed DNS at your own registrar (GoDaddy, Namecheap, Cloudflare, etc.). Fleek gave you the record values, and you added them yourself:
| Record | What you set on Fleek |
|--------|----------------------|
| CNAME | Subdomains pointed at Fleek's infrastructure (later upgraded to BunnyCDN) |
| ANAME / ALIAS | Root domains pointed at Fleek's infrastructure |
| SSL | Automatic: Fleek provisioned Let's Encrypt certificates once DNS was in place |
DNSLink was handled differently depending on your setup:
* **For custom domains using Fleek's CDN**: your CNAME/ANAME record pointed at Fleek's infrastructure. DNSLink wasn't on your domain; Fleek managed it on their end.
* **For DNSLink domains specifically**: Fleek's approach was to CNAME your domain to a Fleek-assigned subdomain (like `yourdomain.on.fleek.co`). The `_dnslink` TXT record lived on Fleek's subdomain, not your custom domain. You just had a CNAME pointing at it.
This means you most likely never had a `_dnslink` TXT record on your own domain if you were using Fleek's standard setup. DNSLink was either managed by Fleek on their infrastructure, or you CNAME'd to a Fleek subdomain that had it.
## How Pinner handles DNS
Pinner gives you two options:
| Option | How it works | Best for |
|--------|--------------|----------|
| **Pinner-managed DNS** | Delegate nameservers to Pinner. Pinner creates and manages DNSLink, validation, and ALIAS records automatically. Updates when you deploy. | People who want the "set it and forget it" experience: closest to what you might have wanted from Fleek |
| **Self-managed DNS** | Keep your DNS at your registrar (just like Fleek). You add and maintain the DNS records yourself. | People who want to stay at Cloudflare, Route53, or another DNS provider |
If you were comfortable managing DNS records on Fleek, the self-managed path will feel familiar: same workflow, different values. If you'd rather not touch DNS records anymore, use Pinner-managed DNS.
## Step-by-step: Switch to Pinner-managed DNS
Delegate nameservers to Pinner, and it handles everything automatically. This is the lowest-effort path.
### 1. Create the website on Pinner
```bash
pinner websites create mysite.example.com --cid
```
By default, Pinner enables DNS hosting for new websites: it creates a DNS zone and the required records automatically.
### 2. Get Pinner's nameservers
```bash
pinner websites config
```
This shows the nameservers Pinner assigned to your domain.
### 3. Switch nameservers at your registrar
Go to the same registrar where you managed DNS for Fleek, and update the nameservers to Pinner's.
This is the step that causes downtime. DNS propagation takes anywhere from a few minutes to 48 hours. Your site may be unreachable during this window.
**To minimize downtime:** Do the nameserver switch during a low-traffic period. Pinner's DNS records are already in place, so the moment the new nameservers propagate, your site starts working on Pinner.
### 4. Verify the switch
After changing nameservers, confirm the delegation:
```bash
dig NS mysite.example.com
```
You should see Pinner's nameservers in the response. If you still see your registrar's default nameservers, propagation isn't complete yet.
### 5. Validate on Pinner
```bash
pinner websites validate mysite.example.com
```
### 6. Wait for SSL
SSL provisions automatically after DNS is confirmed:
```bash
pinner websites ssl status mysite.example.com
```
## Step-by-step: Switch to self-managed DNS
Stay at your current DNS provider. Same approach you used with Fleek; just update the record values to point at Pinner instead.
### 1. Create the website with DNS hosting disabled
```bash
pinner websites create mysite.example.com --cid --no-dns-hosting
```
### 2. Update your DNS records
At your DNS provider, replace the Fleek values with Pinner values:
| Record | Old value (Fleek) | New value (Pinner) |
|--------|-------------------|-------------------|
| CNAME or ALIAS | Fleek's CDN target | Pinner gateway domain |
| TXT `_dnslink` | Probably absent: Fleek managed DNSLink on their end | `dnslink=/ipfs/` or `dnslink=/ipns/` |
If you were using Fleek's standard DNS setup, you likely had a CNAME pointing at Fleek's infrastructure, and Fleek handled DNSLink internally. In that case, the `_dnslink` TXT record is new: Pinner needs it on your domain (unless you use Pinner-managed DNS, which handles it automatically).
Get the Pinner gateway domain from:
```bash
pinner websites config
```
For IPNS targets (recommended if your site updates frequently):
* DNSLink value: `dnslink=/ipns/`: the domain always resolves to the latest content
For direct IPFS targets:
* DNSLink value: `dnslink=/ipfs/`: you'll need to update this record each time you deploy
### 3. Add validation TXT record
The website response includes a `validation_record_host` and `validation_token`. Add:
| Record | Name | Value |
|--------|------|-------|
| TXT | (from `validation_record_host`) | (from `validation_token`) |
### 4. Validate and wait for SSL
```bash
pinner websites validate mysite.example.com
pinner websites ssl status mysite.example.com
```
## Removing the old Fleek records
Once Pinner is serving your site, remove the old Fleek DNS records at your registrar:
* Delete the CNAME/ALIAS record pointing at Fleek's infrastructure
* You likely don't have a `_dnslink` TXT record to remove; Fleek managed that on their end
* Keep any non-Fleek records (email, other subdomains, etc.)
If you're using Pinner-managed DNS, this is handled automatically; delegating the nameservers replaces all the records at once.
## What's different from Fleek
| | Fleek | Pinner |
|---|---|---|
| DNS management | You add CNAME/ANAME records at your registrar | Pinner-managed DNS (automatic) or self-managed (same as Fleek) |
| DNSLink | Manual opt-in via domain settings | Automatic with Pinner-managed DNS; self-managed if you handle DNS yourself |
| SSL | Automatic (Let's Encrypt) | Automatic (Caddy on-demand TLS) |
| Domain verification | TXT record at registrar | TXT record at registrar (same pattern) |
| Record type for root domain | ANAME | ALIAS |
| Record type for subdomain | CNAME | CNAME |
## Troubleshooting
### Validation keeps failing with `dns_missing`
DNS propagation can take up to 48 hours (usually faster: 5 to 30 minutes). Verify the DNSLink and verification TXT records exist:
```bash
dig TXT _dnslink.mysite.example.com
dig TXT
```
### Validation fails with `dns_mismatch`
The DNSLink TXT record was found but doesn't match the expected target. Check that the `_dnslink` TXT record value is correct; it should be `dnslink=/ipfs/` or `dnslink=/ipns/`.
### SSL is stuck in `pending` or `failed`
SSL can only provision after DNS is fully resolved. Check that:
1. The DNSLink record resolves correctly (for self-managed DNS)
2. Nameserver delegation is complete (for Pinner-managed DNS)
3. The validation TXT record is in place
Then check SSL status:
```bash
pinner websites ssl status mysite.example.com
```
### I had multiple domains on Fleek
Each domain is independent. Update the DNS records for each domain separately, the same way you set them up on Fleek.
### I had DNSLink set up on Fleek: do I need a `_dnslink` record now?
Probably yes. On Fleek, DNSLink was handled on Fleek's side; your domain either CNAME'd to a Fleek subdomain that had the `_dnslink` TXT record, or Fleek managed it internally. Either way, the `_dnslink` TXT record was not on your custom domain.
With Pinner, DNSLink needs to resolve from your domain (unless you use Pinner-managed DNS, which handles it automatically). For self-managed DNS, add a TXT record at `_dnslink.yourdomain.com` with the value `dnslink=/ipfs/` or `dnslink=/ipns/`.
## Next steps
* [Deploy with GitHub Actions](/hosting/how-to/deploy-github-actions): automate deploys on push
* [IPNS](/hosting/concepts/ipns): use IPNS for updatable content addresses
* [Custom Domains](/hosting/how-to/custom-domains): full domain setup reference
# Migrate from Pinata
## Why migrate?
* **Decentralized infrastructure**: no single point of failure, no lock-in
* **Competitive pricing**: pay for pinning, not bundled gateway infrastructure
* **Privacy-focused**: your data, your control
* **Compatibility adapter**: swap the SDK, keep most of your call patterns
## Migration strategy
Approach | Best for | Effort |
\----------|----------|--------|
**Pinata Adapter** | Quick migration, minimal code changes | Low |
**Native Pinner API** | Long-term projects, better features | Medium |
:::tip
Start with the adapter to ship fast, then migrate to the native API when you want Pinner-specific features.
:::
## Feature compatibility
The adapter provides broad Pinata API coverage, but some features are not supported because Pinner's architecture differs from Pinata's:
Feature | v2 Adapter | Legacy Adapter | Notes |
\---------|:----------:|:--------------:|-------|
Public file upload | ✅ | ✅ | `file`, `fileArray`, `base64`, `json` |
Pin by CID | ✅ | ✅ | |
Private uploads | ❌ | ❌ | Throws "not supported" |
URL upload | ❌ | N/A | Throws "not supported" in v2 |
File listing | ✅ | ✅ | Pagination filters limited |
Get file by ID | ✅ | ✅ | |
Delete / unpin | ✅ | ✅ | |
Update metadata | ✅ | ✅ | |
Pin queue | ✅ | ✅ | Returns pinned items (no true queue) |
Groups | ⚠️ | N/A | `list()` returns empty; create/add/remove throw |
Gateways | ✅ | N/A | Public gateways only; private throws |
Signed URLs | ❌ | ⚠️ | v2 throws; legacy returns gateway URL fallback |
Swap CID | ❌ | ❌ | Throws "not supported" |
Analytics | ⚠️ | ⚠️ | Returns empty data (not real analytics) |
:::warning
If your app uses private uploads, groups, swap CID, or signed URLs, those calls will throw runtime errors through the adapter. You must remove or replace them before migrating.
:::
For the full adapter method reference, see [Adapter Reference](/migration/pinata/adapter-reference).
## Adapter setup
### Install Pinner
```bash
npm install @lumeweb/pinner
```
### Pick your adapter
**Pinata SDK 2.x**: use `pinataAdapter`:
```typescript
import { Pinner, pinataAdapter } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const adapter = pinataAdapter(pinner);
```
**Pinata SDK 1.x**: use `pinataLegacyAdapter`:
```typescript
import { Pinner, pinataLegacyAdapter } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const adapter = pinataLegacyAdapter(pinner);
```
## Step-by-step: replace initialization
1. **Replace the import.** Swap `pinata` for `@lumeweb/pinner` and import the adapter.
2. **Replace the constructor.** Instead of `new PinataSDK({ pinataJwt })`, use `new Pinner({ jwt })` + adapter.
3. **Handle unsupported features.** Remove or replace calls to private uploads, groups, swap CID, signed URLs, and URL uploads (see [Feature compatibility](#feature-compatibility)).
4. **Add `.execute()` to v2 builder methods.** The v2 adapter uses a builder pattern: chain `.name()` and `.keyvalues()`, then call `.execute()`.
5. **Test uploads and listings.** Verify your existing calls work with the adapter.
## Error handling
Pinner errors use a different structure. Instead of catching generic errors:
```typescript
import { PinnerError } from "@lumeweb/pinner";
try {
const result = await adapter.upload.public.file(file).execute();
} catch (error) {
if (error instanceof PinnerError) {
console.error("Code:", error.code);
console.error("Message:", error.message);
console.error("Retryable:", error.retryable);
}
}
```
`PinnerError` has the following properties:
Property | Type | Description |
\----------|------|-------------|
`code` | `string` | Machine-readable error code (e.g. `"UPLOAD_ERROR"`, `"PIN_ERROR"`) |
`message` | `string` | Human-readable error message |
`retryable` | `boolean` | Whether the operation can be retried |
See the [Error Reference](/ipfs/reference/errors) for all error codes and retry guidance.
## Migration checklist
* \[ ] Install `@lumeweb/pinner`
* \[ ] Update imports and initialization
* \[ ] Remove or replace unsupported features (private uploads, groups, swap CID, signed URLs, URL uploads)
* \[ ] Add `.execute()` to builder methods (v2 adapter only)
* \[ ] Test file uploads
* \[ ] Test file listing and deletion
* \[ ] Update error handling
* \[ ] Remove Pinata SDK dependency
* \[ ] Deploy and monitor
## Testing your migration
1. **Test in development**: verify all upload operations work
2. **Check file listings**: ensure metadata is preserved
3. **Verify deletions**: test unpin operations
4. **Test unsupported feature handling**: confirm your app gracefully handles `notSupported` errors
5. **Monitor performance**: compare upload speeds
6. **Review costs**: check your billing
## Next steps
* [Adapter Reference](/migration/pinata/adapter-reference): full v2 and legacy adapter method docs
* [Code Examples](/migration/pinata/examples): side-by-side Pinata vs Pinner code
* [SDK Getting Started](/ipfs/sdk/getting-started): using Pinner without the adapter
# Pinata Adapter Reference
Complete method reference for the Pinata compatibility adapters. For setup instructions, see the [Migration Guide](/migration/pinata).
## v2 Adapter
The v2 adapter mirrors the Pinata SDK 2.x structure with `public`/`private` namespaces. It uses a builder pattern: chain methods and call `.execute()`.
### Upload
```typescript
// Upload a single file
const result = await adapter.upload.public.file(file)
.name("my-file")
.keyvalues({ category: "image" })
.execute();
// Upload JSON
const jsonResult = await adapter.upload.public.json({ hello: "world" })
.name("data")
.execute();
// Upload multiple files (directory)
const dirResult = await adapter.upload.public.fileArray([file1, file2])
.name("my-dir")
.execute();
// Pin by CID
const pinResult = await adapter.upload.public.cid("Qm...").execute();
// Base64 upload
const b64Result = await adapter.upload.public.base64(base64String).execute();
```
### Files
```typescript
// List files (returns a thenable builder)
const files = await adapter.files.public.list()
.name("my-file")
.limit(10)
.order("DESC");
// Get a specific file
const file = await adapter.files.public.get("Qm...");
// Delete files
await adapter.files.public.delete(["Qm..."]);
// Update file metadata
await adapter.files.public.update({ id: "Qm...", keyvalues: { tags: "updated" } });
// Pin queue
const queue = await adapter.files.public.queue().limit(10);
```
### Gateways
```typescript
// Get gateway info for a CID
const info = adapter.gateways.public.get("Qm...");
console.log(info.url); // "https://QmExample.ipfs.inbrowser.link"
// Convert IPFS URL to gateway URL
const gatewayUrl = await adapter.gateways.public.convert("ipfs://Qm...");
```
## Legacy Adapter
The legacy adapter uses flat method names matching Pinata SDK 1.x. No builder pattern; pass options directly.
```typescript
// Upload a file
const result = await adapter.pinFileToIPFS(file, {
metadata: { name: "my-file", keyvalues: { category: "image" } }
});
// Upload JSON
const jsonResult = await adapter.pinJSONToIPFS({ hello: "world" }, {
metadata: { name: "data" }
});
// Pin by CID
const pinResult = await adapter.pinByHash("Qm...", {
metadata: { name: "pinned-file" }
});
// List pinned files
const { files } = await adapter.pinList({ limit: 10 });
// Unpin
await adapter.unpin("Qm...");
// Update metadata
await adapter.hashMetadata("Qm...", { tags: "updated" });
// Pin jobs
const { rows } = await adapter.pinJobs({ limit: 10 });
```
## Unsupported features
Some Pinata features throw when called through the adapter because Pinner has no equivalent:
Feature | v2 Adapter | Legacy Adapter | What to do instead |
\---------|:----------:|:--------------:|-------------------|
Private uploads | Throws `notSupported` | Throws `notSupported` | Use Pinner's native API |
Groups | `list()` returns empty; create/add/remove throw | N/A | Use key-value metadata for categorisation |
Signed URLs | Throws `notSupported` | Returns gateway URL fallback | Use public gateway URLs |
| Swap CID | Throws `notSupported` | Throws `notSupported` | Not available; delete and re-pin |
URL uploads | Throws `notSupported` | N/A | Fetch the URL yourself and upload the file |
Analytics | Returns empty data | Returns empty data | Not available |
## Next steps
* [Code Examples](/migration/pinata/examples): side-by-side Pinata vs Pinner vs native API code
* [Migration Guide](/migration/pinata): setup and step-by-step instructions
* [SDK Getting Started](/ipfs/sdk/getting-started): using Pinner without the adapter
# Pinata Code Examples
Side-by-side code for every common operation. For setup instructions, see the [Migration Guide](/migration/pinata). For the full adapter method reference, see [Adapter Reference](/migration/pinata/adapter-reference).
## Pinata SDK 2.x
### File Upload
```typescript
// Pinata SDK 2.x
import { PinataSDK } from "pinata";
const pinata = new PinataSDK({
pinataJwt: process.env.PINATA_JWT
});
const file = new File(["Hello!"], "hello.txt", { type: "text/plain" });
const upload = await pinata.upload.public.file(file);
console.log("CID:", upload.cid);
console.log("Name:", upload.name);
```
```typescript
// Pinner with Adapter
import { Pinner, pinataAdapter } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
const adapter = pinataAdapter(pinner);
const file = new File(["Hello!"], "hello.txt", { type: "text/plain" });
const upload = await adapter.upload.public.file(file)
.name("hello.txt")
.execute();
console.log("CID:", upload.cid);
console.log("Name:", upload.name);
```
### JSON Upload
```typescript
// Pinata SDK 2.x
const data = { name: "test", value: 123 };
const upload = await pinata.upload.public.json(data);
```
```typescript
// Pinner with Adapter
const data = { name: "test", value: 123 };
const upload = await adapter.upload.public.json(data)
.name("data.json")
.execute();
```
### Base64 Upload
```typescript
// Pinata SDK 2.x
const base64String = "SGVsbG8gV29ybGQh";
const upload = await pinata.upload.public.base64(base64String);
```
```typescript
// Pinner with Adapter
const base64String = "SGVsbG8gV29ybGQh";
const upload = await adapter.upload.public.base64(base64String)
.name("file.txt")
.execute();
```
### Pin by CID
```typescript
// Pinata SDK 2.x
const pin = await pinata.upload.public.cid("QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco");
```
```typescript
// Pinner with Adapter
const pin = await adapter.upload.public.cid("QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco")
.execute();
```
### List Files
```typescript
// Pinata SDK 2.x
const files = await pinata.files.public.list()
.limit(10)
.all();
for (const file of files) {
console.log(file.name, file.cid);
}
```
```typescript
// Pinner with Adapter
const files = await adapter.files.public.list()
.limit(10)
.all();
for (const file of files) {
console.log(file.name, file.cid);
}
```
### Directory Upload
```typescript
// Pinata SDK 2.x
const files = [
new File(["content"], "file1.txt"),
new File(["content"], "file2.txt"),
];
const upload = await pinata.upload.public.fileArray(files);
```
```typescript
// Pinner with Adapter
const files = [
new File(["content"], "file1.txt"),
new File(["content"], "file2.txt"),
];
const upload = await adapter.upload.public.fileArray(files)
.name("my-directory")
.execute();
```
### Delete Files
```typescript
// Pinata SDK 2.x
await pinata.files.public.delete(["QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco"]);
```
```typescript
// Pinner with Adapter
await adapter.files.public.delete(["QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco"]);
```
## Pinata SDK 1.x Legacy
### File Upload
```typescript
// Pinata SDK 1.x
const file = new File(["Hello!"], "hello.txt", { type: "text/plain" });
const upload = await pinata.pinFileToIPFS(file);
console.log("IpfsHash:", upload.IpfsHash);
```
```typescript
// Pinner with Legacy Adapter
import { Pinner, pinataLegacyAdapter } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
const adapter = pinataLegacyAdapter(pinner);
const file = new File(["Hello!"], "hello.txt", { type: "text/plain" });
const upload = await adapter.pinFileToIPFS(file);
console.log("CID:", upload.cid);
```
### JSON Upload
```typescript
// Pinata SDK 1.x
const data = { name: "test", value: 123 };
const upload = await pinata.pinJSONToIPFS(data);
```
```typescript
// Pinner with Legacy Adapter
const data = { name: "test", value: 123 };
const upload = await adapter.pinJSONToIPFS(data);
```
### Pin by CID
```typescript
// Pinata SDK 1.x
const pin = await pinata.pinByHash("QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco");
```
```typescript
// Pinner with Legacy Adapter
const pin = await adapter.pinByHash("QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco");
```
### List Pins
```typescript
// Pinata SDK 1.x
const pins = await pinata.pinList({ limit: 10 });
for (const pin of pins.files) {
console.log(pin.ipfs_pin_hash);
}
```
```typescript
// Pinner with Legacy Adapter
const pins = await adapter.pinList({ limit: 10 });
for (const pin of pins.files) {
console.log(pin.cid);
}
```
### Unpin
```typescript
// Pinata SDK 1.x
await pinata.unpin("QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco");
```
```typescript
// Pinner with Legacy Adapter
await adapter.unpin("QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco");
```
### Update Metadata
```typescript
// Pinata SDK 1.x
await pinata.hashMetadata("QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco", {
project: "my-app",
version: "2.0"
});
```
```typescript
// Pinner with Legacy Adapter
await adapter.hashMetadata("QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco", {
project: "my-app",
version: "2.0"
});
```
## Native Pinner API
Skip the adapter and use Pinner directly for the best experience.
### File Upload
```typescript
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
const file = new File(["Hello!"], "hello.txt", { type: "text/plain" });
const result = await pinner.uploadAndWait(file);
console.log("CID:", result.cid);
```
### Upload with Progress
```typescript
const operation = await pinner.upload(file, {
name: "hello.txt",
keyvalues: { project: "demo" },
onProgress: (progress) => {
console.log(`Progress: ${progress.percentage.toFixed(1)}%`);
console.log(`Uploaded: ${progress.bytesUploaded} / ${progress.bytesTotal} bytes`);
}
});
const result = await operation.result;
console.log("CID:", result.cid);
console.log("Final progress:", operation.progress.percentage);
```
### Pin Management
```typescript
// Pin by CID
for await (const cid of pinner.pinByHash("QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco")) {
console.log("Pinned:", cid.toString());
}
// List pins
const pins = await pinner.listPins();
for (const pin of pins) {
console.log(pin.cid.toString(), pin.name);
}
// Unpin
await pinner.unpin("QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco");
```
## Error Handling
```typescript
// Pinata SDK
try {
const upload = await pinata.upload.public.file(file);
} catch (error) {
console.error("Pinata error:", error.message);
}
```
```typescript
// Pinner SDK
import { PinnerError } from "@lumeweb/pinner";
try {
const result = await pinner.upload(file);
} catch (error) {
if (error instanceof PinnerError) {
console.error("Code:", error.code);
console.error("Message:", error.message);
}
}
```
# Quickstart
Choose your path; both get a file pinned to IPFS in under 5 minutes.
:::code-group
```bash [CLI]
# 1. Install (macOS / Linux)
curl -fsSL https://get.pinner.xyz | sh
# Windows: iex (irm https://get.pinner.xyz/install.ps1)
# 2. Set up (walks you through auth + config)
pinner setup
# 3. Upload a file
pinner upload myfile.txt
# Done: your CID is printed to the terminal
```
```typescript [SDK]
npm install @lumeweb/pinner
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
// Upload and wait for the CID
const result = await pinner.uploadAndWait(file);
console.log("CID:", result.cid);
```
:::
## Go deeper
* [CLI install & setup guide](/ipfs/cli/getting-started): all install methods, flags, troubleshooting
* [SDK getting started](/ipfs/sdk/getting-started): install, configure, first upload, type your config
* [Understand CIDs](/ipfs/concepts/cids)
* [Check if your content is pinned](/ipfs/how-to/check-pin-status)
# Upload a Single File
Pin a file to IPFS and get its Content Identifier (CID). This is the most common upload: a single file, named, and confirmed.
## Quick upload
:::code-group
```bash [CLI]
pinner upload myfile.txt
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const file = new File(["Hello, IPFS!"], "hello.txt", { type: "text/plain" });
const result = await pinner.uploadAndWait(file);
console.log("CID:", result.cid);
```
:::
The CLI prints the CID, gateway URL, size, and upload time. The SDK returns an `UploadResult` with the same fields.
## Name your upload
By default the CLI uses the filename and the SDK uses the `File` object's name. Override it when you want a different name on the pin record.
:::code-group
```bash [CLI]
pinner upload ./report.pdf --name "Q4 Report"
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const file = new File([buffer], "report.pdf", { type: "application/pdf" });
const result = await pinner.uploadAndWait(file, { name: "Q4 Report" });
console.log("CID:", result.cid);
```
:::
## Add metadata
Attach key-value pairs for organisation and search. Metadata is returned when you [list pins](/ipfs/how-to/list-pins).
:::code-group
```bash [CLI]
pinner upload logo.svg --meta project=acme --meta env=prod
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const file = new File([svgData], "logo.svg", { type: "image/svg+xml" });
const result = await pinner.uploadAndWait(file, {
keyvalues: { project: "acme", env: "prod" },
});
console.log("CID:", result.cid);
```
:::
## Fire-and-forget vs. wait for confirmation
By default both the CLI and `uploadAndWait` wait until the pin is confirmed. For large files or CI pipelines, you can skip the wait.
:::code-group
```bash [CLI]
pinner upload bigfile.tar.gz --no-wait
```
```typescript [SDK – fire and forget]
const operation = await pinner.upload(file);
// Don't await operation.result; the upload is in progress
console.log("Upload started, operation ID:", operation.progress);
// Later, poll for the result:
const result = await operation.result;
console.log("CID:", result.cid);
```
```typescript [SDK – wait with custom polling]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const operation = await pinner.upload(file);
const result = await pinner.waitForOperation(operation, {
interval: 3000,
timeout: 120000,
});
console.log("CID:", result.cid);
```
:::
## Use the builder pattern
The SDK's builder pattern chains upload options in a fluent style.
```typescript
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const operation = await pinner.upload
.file(file)
.name("config.json")
.keyvalues({ team: "platform" })
.pin();
const result = await operation.result;
console.log("CID:", result.cid);
```
## Node.js: read from the filesystem
In Node.js 20+, the global `File` class is available. Read a file from disk and upload it:
```typescript
import { Pinner } from "@lumeweb/pinner";
import { readFileSync } from "fs";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const buffer = readFileSync("./data.csv");
const file = new File([buffer], "data.csv", { type: "text/csv" });
const result = await pinner.uploadAndWait(file);
console.log("CID:", result.cid);
```
## Upload from stdin
The CLI can read from a pipe:
```bash
cat config.json | pinner upload --name "config.json"
```
When no file path is given and stdin is piped, the CLI reads from the pipe. The default name is `"stdin"`; override it with `--name`.
## Next steps
* [Upload an entire directory](/ipfs/how-to/upload-directory)
* [Upload CAR files for pre-calculated CIDs](/ipfs/how-to/upload-car)
* [Check the status of your pin](/ipfs/how-to/check-pin-status)
* [Add and update metadata on existing pins](/ipfs/how-to/manage-metadata)
# Upload a Directory
Upload multiple files as an IPFS directory and get the root CID that addresses the whole structure.
## Upload the directory
:::code-group
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const files = [
new File(["Hello, IPFS!"], "hello.txt", { type: "text/plain" }),
new File(['{"name": "config"}'], "config.json", {
type: "application/json",
}),
];
const operation = await pinner.uploadDirectory(files);
const result = await operation.result;
console.log("Root CID:", result.cid);
```
```bash [CLI]
# Upload an entire directory
pinner upload ./my-directory/
# The root CID, gateway URL, size, and duration are printed to the terminal
```
:::
## What is the root CID?
When you upload a directory, IPFS creates a tree of CIDs: one for each file, plus intermediate nodes. The **root CID** addresses the top of that tree. With the root CID alone you can reach every file inside the directory.
## Verify the upload
You can check that the content is reachable through a public gateway:
```
https://.ipfs.inbrowser.link/hello.txt
```
Or check the pin status:
:::code-group
```typescript [SDK]
const pinInfo = await pinner.pins.get(result.cid);
console.log(pinInfo.status);
```
```bash [CLI]
pinner status
```
:::
## Next steps
* [Upload CAR files for pre-calculated CIDs](/ipfs/how-to/upload-car)
* [Understand how CIDs work](/ipfs/concepts/cids)
# Upload CAR Files
CAR (Content Addressable Archive) files let you upload IPFS data with the CID already known before the upload even starts. This is useful for bulk uploads and when you need to guarantee the CID ahead of time.
Not sure what CAR files are or why you'd use them? See [CAR Files](/ipfs/concepts/car-files).
## Upload a CAR file
:::code-group
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
import fs from "fs";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const carData = fs.readFileSync("./data.car");
const carFile = new File([carData], "data.car", {
type: "application/vnd.ipld.car",
});
const operation = await pinner.uploadCar(carFile);
const result = await operation.result;
console.log("Root CID:", result.cid);
```
```typescript [SDK – Builder]
import { Pinner } from "@lumeweb/pinner";
import fs from "fs";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const carData = fs.readFileSync("./data.car");
const carFile = new File([carData], "data.car", {
type: "application/vnd.ipld.car",
});
const result = await pinner.upload
.raw(carFile)
.name("data.car")
.waitForOperation(true)
.pin();
// result is an UploadResult with .cid
console.log("Root CID:", result.cid);
```
```bash [CLI]
pinner upload data.car
```
:::
### ReadableStream input
`uploadCar` also accepts a `ReadableStream`, which is useful in browser environments or when streaming CAR data without buffering the entire file:
```typescript
const stream: ReadableStream = getCarStream();
const operation = await pinner.uploadCar(stream, {
name: "data.car",
size: carByteLength,
});
```
When providing a `ReadableStream`, set `name` and `size` in the options so the upload handler can choose the right protocol (XHR vs. TUS resumable) and apply the correct MIME type.
### Marking regular uploads as CAR
If you use the generic `pinner.upload()` method (not `uploadCar`), you can pass `isCarFile: true` in the options to skip CAR preprocessing and upload the file as-is:
```typescript
await pinner.upload(carFile, { isCarFile: true });
```
This is useful when you already have a CAR file and don't want the SDK to re-process it into another CAR.
## Why CAR instead of a regular upload?
With a CAR file the root CID is deterministic; it's computed from the IPFS block structure before the upload begins. You can verify the CID locally and know it won't change after pinning.
Regular uploads also work fine. CAR is for cases where you need the CID upfront: deterministic builds, verification pipelines, or bulk transfers of pre-packaged IPFS data.
## Next steps
* [Learn how CAR files work](/ipfs/concepts/car-files)
* [Upload a directory](/ipfs/how-to/upload-directory)
# List & Search Pins
See what you've pinned, filter by name or status, and paginate through large lists of pins.
## List all pins
:::code-group
```bash [CLI]
pinner pins ls
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const pins = await pinner.listPins();
for (const pin of pins) {
console.log(`${pin.cid} ${pin.name ?? "(unnamed)"} ${pin.status}`);
}
```
:::
The CLI prints a table with CID, name, status, and created date. The SDK returns an array of `RemotePin` objects.
## Filter by name
Find pins whose name matches a string.
:::code-group
```bash [CLI]
pinner pins ls --name "logo"
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const pins = await pinner.listPins({ name: "logo" });
```
:::
## Filter by status
Show only pins in a specific state. Status values: `queued`, `pinning`, `pinned`, `failed`.
:::code-group
```bash [CLI]
pinner pins ls --status failed
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const pins = await pinner.listPins({ status: ["failed"] });
```
:::
Combine name and status filters:
```bash
pinner pins ls --name "deploy" --status pinned
```
## Limit results
Control how many results come back per page. The CLI defaults to 10; the SDK defaults to the server's page size.
:::code-group
```bash [CLI]
pinner pins ls --limit 50
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const pins = await pinner.listPins({ limit: 50 });
```
:::
## Paginate with the SDK
`listPins()` returns a flat array; fine for most cases. When you need more control (streaming large result sets, custom pagination), use the async generator directly:
```typescript
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
// Stream pins one at a time
for await (const pin of pinner.pins.ls()) {
console.log(pin.cid, pin.name, pin.status);
// Break early if you only need a window
if (someCondition) break;
}
```
The generator handles pagination internally; it fetches the next page when you exhaust the current one. Pass `cursor` to resume from a specific point:
```typescript
for await (const pin of pinner.pins.ls({ cursor: previousCursor })) {
// picks up where you left off
}
```
## Watch for status changes
The CLI can continuously monitor pin status:
```bash
pinner pins ls --watch
```
The table refreshes in place, showing the current status of each pin. Useful when you're waiting for bulk pins to settle. Press `Ctrl+C` to stop.
## Look up a single pin by CID
If you already know the CID, use `pins.get` instead of listing and filtering:
:::code-group
```bash [CLI]
pinner pins status bafybeig...
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
import { CID } from "multiformats";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const pin = await pinner.pins.get(CID.parse("bafybeig..."));
console.log(pin.status);
```
:::
See [Check Pin Status](/ipfs/how-to/check-pin-status) for the full guide.
## What you'll see
Each pin has these fields:
| Field | Description |
|-------|-------------|
| `cid` | The Content Identifier for the pinned data |
| `name` | Human-readable name (if set during upload) |
| `status` | Current state: `queued`, `pinning`, `pinned`, or `failed` |
| `created` | When the pin was created |
| `metadata` | Key-value pairs attached to the pin |
For a deeper explanation of what each status means and how pins transition between them, see [Pin Lifecycle](/ipfs/concepts/pin-lifecycle).
## Next steps
* [Check a specific pin's status](/ipfs/how-to/check-pin-status)
* [Remove pins you no longer need](/ipfs/how-to/delete-pins)
* [Add and update metadata on pins](/ipfs/how-to/manage-metadata)
# Check Pin Status
Find out whether your content is queued, pinning, pinned, or failed.
:::code-group
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const pin = await pinner.pins.get(CID.parse("bafybeig..."));
console.log(pin.status);
// → "pinned"
```
```bash [CLI]
pinner pins status bafybeig...
```
:::
## What the statuses mean
| Status | Meaning |
|--------|---------|
| `queued` | Waiting to be pinned |
| `pinning` | Pin is being processed |
| `pinned` | Stored and retrievable |
| `failed` | Pinning failed; check the error message |
For a deeper explanation of these states, see [Pin Lifecycle](/ipfs/concepts/pin-lifecycle).
# Delete / Unpin Content
Remove a pin by its CID. Once unpinned, the content is no longer stored by Pinner and may become unavailable on the IPFS network.
:::info
Unpinning removes the content from Pinner's storage. If other nodes on the IPFS network still pin the same CID, the content remains accessible through those nodes. If your CID is only pinned by Pinner, it may become unreachable after unpinning.
:::
## Unpin a single CID
:::code-group
```bash [CLI]
pinner pins rm bafybeig... --force
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
await pinner.unpin("bafybeig...");
```
:::
The CLI prints `Unpinned CID: ` on success. The SDK method resolves when the pin is removed.
Without `--force`, the CLI prompts for confirmation:
```bash
pinner pins rm bafybeig...
# → "Are you sure you want to unpin bafybeig...?"
```
## Unpin multiple CIDs
Remove several pins in one command. The CLI accepts space-separated CIDs.
:::code-group
```bash [CLI]
pinner pins rm bafybeig...abc bafybeig...def --force --parallel 5
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const cids = ["bafybeig...abc", "bafybeig...def"];
for (const cid of cids) {
await pinner.unpin(cid);
console.log(`Unpinned ${cid}`);
}
```
:::
The CLI's `--parallel` flag controls how many unpin operations run concurrently. `--continue` keeps going even if some operations fail; for example:
```bash
pinner pins rm cid1 cid2 cid3 --force --parallel 3 --continue
```
## Unpin from a file
Read CIDs from a text file (one per line):
```bash
pinner pins rm --file cids-to-remove.txt --force
```
## Unpin by request ID
If you have a pin request ID instead of a CID (for example, from a failed upload), remove it by request ID:
```typescript
await pinner.unpinByRequestId("request-id-here");
```
The CLI does not expose request IDs directly; use `pinner pins ls` to find the CID, then unpin by CID.
## Unpin all pins
Remove every pin at once. Requires `--force` for safety.
```bash
pinner pins rm --all --force
```
Combine with `--status` to unpin only pins in a specific state: for example, remove all failed pins:
```bash
pinner pins rm --all --status failed --force
```
## Dry run
Preview what would be removed without making any changes:
```bash
pinner pins rm bafybeig... --dry-run
```
The CLI prints the operations it would perform, including the endpoint and CIDs, but does not actually unpin anything.
## Next steps
* [List your pins](/ipfs/how-to/list-pins)
* [Check a specific pin's status](/ipfs/how-to/check-pin-status)
* [Understand the pin lifecycle](/ipfs/concepts/pin-lifecycle)
# Batch Pin Files
Pin many files or CIDs without running commands one at a time.
## Pin from a list of CIDs
:::code-group
```bash [CLI]
# One CID per line in a text file
pinner pins add --file cids.txt
# Multiple CIDs as arguments
pinner pins add bafybeig...abc bafybeig...def --parallel 5
# Pipe from stdin
echo "bafybeig...abc" | pinner pins add
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
import fs from "fs";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const cids = fs.readFileSync("cids.txt", "utf-8")
.split("\n")
.filter(Boolean);
for (const cid of cids) {
try {
const generator = pinner.pins.add(cid);
for await (const pinned of generator) {
console.log(`Pinned ${pinned}`);
}
} catch (err) {
console.error(`Failed to pin ${cid}:`, err);
}
}
```
:::
## Upload multiple files as a directory
:::code-group
```bash [CLI]
# Upload every file in a directory (creates a single root CID)
pinner upload ./my-files/
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
import fs from "fs";
import path from "path";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
const dir = "./my-files";
const files = fs.readdirSync(dir).map((name) => {
const buffer = fs.readFileSync(path.join(dir, name));
return new File([buffer], name);
});
const operation = await pinner.uploadDirectory(files);
const result = await operation.result;
console.log("Root CID:", result.cid);
```
:::
# Manage Pin Metadata
Attach key-value pairs to your pins for organisation and search.
## Set metadata
:::code-group
```bash [CLI]
pinner pins update bafybeig... --meta project=acme --meta env=prod
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
await pinner.pins.setMetadata(cid, { project: "acme", env: "prod" });
```
:::
## Clear metadata
Use `--clear-meta` to remove all existing metadata before setting new values.
:::code-group
```bash [CLI]
# Clear all metadata
pinner pins update bafybeig... --clear-meta
# Clear and set fresh values in one command
pinner pins update bafybeig... --clear-meta --meta fresh=start
```
```typescript [SDK]
// Clear all metadata by passing undefined
await pinner.pins.setMetadata(cid, undefined);
```
:::
## Read metadata
:::code-group
```bash [CLI]
pinner pins status bafybeig...
```
```typescript [SDK]
const pin = await pinner.pins.get(cid);
console.log(pin.metadata);
```
:::
# Automate Pinning with GitHub Actions
Automatically upload build artifacts to IPFS every time you push to your repository.
## Set up secrets
Add your Pinner API key as a repository secret:
1. Go to **Settings → Secrets and variables → Actions**
2. Click **New repository secret**
3. Name: `PINNER_AUTH_TOKEN`
4. Value: your API key from [API Keys](/account/api-tokens)
## Create the workflow
Create `.github/workflows/pinner.yml`:
```yaml
name: Pin to IPFS
on:
push:
branches: [main]
jobs:
pin:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Pinner CLI
run: go install go.lumeweb.com/pinner-cli/cmd/pinner@latest
- name: Authenticate
run: pinner auth ${{ secrets.PINNER_AUTH_TOKEN }}
- name: Upload build output
id: upload
run: pinner upload ./dist/ --name "ci-build"
- name: Verify pin
run: pinner status ${{ steps.upload.outputs.cid }}
```
## How it works
1. On every push to `main`, the workflow installs the Pinner CLI
2. It authenticates with your JWT token using `pinner auth`
3. It uploads the `./dist/` directory with `pinner upload`
4. It verifies the pin was created with `pinner status`
The `pinner upload` command outputs the CID, gateway URL, size, and duration. The `pinner status` command checks whether the pin has reached a terminal state (`pinned` or `failed`).
## Using the SDK instead
If you prefer the JavaScript SDK in a Node.js script:
```yaml
- name: Upload with SDK
env:
PINNER_AUTH_TOKEN: ${{ secrets.PINNER_AUTH_TOKEN }}
run: node scripts/upload-to-ipfs.mjs
```
```javascript
// scripts/upload-to-ipfs.mjs
import { Pinner } from "@lumeweb/pinner";
import fs from "fs";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
const files = fs.readdirSync("./dist").map((name) => {
const buffer = fs.readFileSync(`./dist/${name}`);
return new File([buffer], name);
});
const operation = await pinner.uploadDirectory(files, { name: "ci-build" });
const result = await operation.result;
console.log("Root CID:", result.cid);
```
## Next steps
* [Check pin status programmatically](/ipfs/how-to/check-pin-status)
* [Batch pin files](/ipfs/how-to/batch-pin)
# Access Pinned Content
Pinner pins your content to IPFS. To retrieve it, use one of these options.
## Pinner API
The IPFS plugin exposes an authenticated content retrieval endpoint. Send a `GET` request to the IPFS subdomain:
```
GET https://ipfs.pinner.xyz/ipfs/
```
This returns the raw content for the specified CID and supports streaming for large files. You can also send a `HEAD` request to check whether content exists without downloading it.
All requests require a valid JWT token (see [API Keys](/account/api-tokens)).
Example with `curl`:
```bash
curl -H "Authorization: Bearer " \
"https://ipfs.pinner.xyz/ipfs/bafybeig...example"
```
## ipfs.pub
[ipfs.pub](https://ipfs.pub) is Pinner's own IPFS gateway. It serves content pinned or hosted through Pinner; it's not an anonymous public gateway, so it's fast and reliable for your content.
```
https://ipfs.pub/ipfs/
```
## Pinner CLI
The Pinner CLI provides three content retrieval commands:
* **`pinner cat `**: stream content to stdout (like `ipfs cat`, but through Pinner's API)
* **`pinner download `**: save content to a file (supports `CID/path` for specific files, `--force` to overwrite, `--output` to set a path)
* **`pinner ls `**: list the contents of a pinned directory
All CLI commands authenticate using your stored token (set up via `pinner auth login`).
Examples:
```bash
# Stream a file to stdout
pinner cat bafybeig...example
# Download to a specific file
pinner download bafybeig...example -o myfile.txt
# List directory contents
pinner ls bafybeig...example
```
## Website hosting
If you're serving content to the public, the best option is [website hosting](/hosting/quickstart). Your content gets its own domain with automatic SSL, no CID in the URL:
```
https://mysite.example.com
```
See [How Hosting Works](/hosting/concepts/how-hosting-works) for the full setup.
## Public gateways
Community-operated public gateways let you retrieve any IPFS content over HTTP. The main surviving public gateway is:
| Gateway | URL format | Notes |
|---------|------------|-------|
| `inbrowser.link` | `https://.ipfs.inbrowser.link` | IPFS Project's service worker gateway. Runs in subdomain mode with origin isolation. Drop-in replacement for the retired `dweb.link`. |
[inbrowser.link](https://inbrowser.link) uses a service worker to fetch and verify IPFS content directly in the browser, reducing reliance on a single HTTP backend. It's the IPFS Project's recommended public gateway.
Public gateways are not affiliated with Pinner. Availability and performance vary; for production use, we recommend [ipfs.pub](https://ipfs.pub) (via [website hosting](/hosting/quickstart)) or running your own gateway.
For a live list of public gateways and their current availability and latency, see [check.ipfs.pub](https://check.ipfs.pub) (our deployment of [check.ipfs.network](https://check.ipfs.network)) or the [IPFS Public Gateway Checker](https://ipfs.github.io/public-gateway-checker/).
### SDK default gateway
The Pinner JS SDK uses `https://inbrowser.link` as its default gateway for content retrieval. You can override this by setting the `gateway` option in your `PinnerConfig`:
```typescript
const pinner = new Pinner({
jwt: "",
gateway: "https://ipfs.pub", // override the default
});
```
The CLI falls back to `https://ipfs.pinner.xyz/ipfs/` when no custom gateway is configured, or you can set `gateway_endpoint` in your config.
## Local IPFS
If you run IPFS locally, you already have access:
* **IPFS Desktop**: graphical app with built-in gateway at `http://localhost:8080`
* **IPFS Companion**: browser extension that resolves IPFS addresses automatically
* **ipfs-cli**: `ipfs cat ` from the terminal
## Custom or private gateway
If you need guaranteed uptime, low latency, or private access, run your own gateway. Popular options include [Rainbow](https://github.com/ipfs/rainbow) and [Kubo](https://github.com/ipfs/kubo) with the gateway enabled.
## Why doesn't Pinner include a public anonymous gateway?
See [Why No Public Gateway](/ipfs/concepts/why-no-gateway) for the reasoning.
# Troubleshoot Uploads
Fix common problems when uploading or pinning content. If you don't see your issue here, run `pinner doctor` to gather diagnostic information about your CLI version, configuration, and authentication status.
For specific error codes, see [Error Reference](/ipfs/reference/errors).
## Auth issues
### "Not authenticated"
No auth token is configured. Authenticate with a JWT token:
```bash
pinner auth YOUR_JWT_TOKEN
```
Or log in interactively (prompts for email, password, and 2FA if enabled):
```bash
pinner auth --email your@email.com
```
Or fully interactive:
```bash
pinner auth
```
### "Authentication expired or invalid"
Your session token has expired or is invalid. Re-authenticate:
```bash
pinner auth --email your@email.com
```
Or set a token directly:
```bash
pinner auth YOUR_JWT_TOKEN
```
### Email confirmation not received
After registration you must confirm your email. If the verification email doesn't arrive:
1. Check your spam folder
2. Verify the email address is correct
3. Run the confirmation manually with the token from the email:
```bash
pinner confirm-email --email your@email.com --token VERIFICATION_TOKEN
```
## Connection errors
### "Connection failed" or "Network timeout"
1. Check your internet connection
2. Verify the API endpoint is reachable:
```bash
curl -I https://pinner.xyz
```
3. Check firewall or proxy settings
4. Try with verbose output to see where it fails:
```bash
pinner --verbose upload myfile.txt
```
## File errors
### "File not found"
Verify the file path is correct. Use absolute paths if relative paths fail:
```bash
pinner upload /absolute/path/to/file.txt
```
### "Permission denied"
Check file ownership and permissions:
```bash
ls -la /path/to/file
chmod 644 /path/to/file
```
### "Cannot upload empty file"
Empty files (0 bytes) are rejected. Verify the file has content:
```bash
ls -la /path/to/file
```
### Upload interrupted
If an upload is interrupted (Ctrl+C or network drop), retry the same command. The CLI uses the TUS resumable upload protocol for files larger than the upload limit (fetched from your account, default 100 MB), and XHR upload for smaller files:
```bash
pinner upload file.dat
```
By default, the command waits for pinning to complete. Use `--no-wait` to return immediately after the upload finishes:
```bash
pinner upload file.dat --no-wait
```
## Pinning errors
### "Pin not found"
The CID is not pinned in your account. Verify:
```bash
pinner list
```
### "Pinning failed"
1. Verify the CID exists on IPFS
2. Check your network connection
3. Check the pin status:
```bash
pinner status bafybeig...
```
4. Try again later; transient issues do happen
### Invalid CID format
CIDs must start with a valid multibase prefix. Common CID formats include:
* `Qm...`: CIDv0 (Base58BTC, SHA-2 256, 32 bytes)
* `bafy...`: CIDv1 (DAG-PB, SHA-2 256, mutable-length multihash)
* `bafk...`: CIDv1 (raw or single-block, SHA-2 256)
CIDs are case-sensitive.
## Config issues
### Config file location
| OS | Path |
|----|------|
| Linux / macOS | `~/.config/pinner/config.yaml` |
| Windows | `%APPDATA%\pinner\config.yaml` |
### "Configuration not found"
Run setup to create the config file:
```bash
pinner setup
```
### "Configuration invalid"
Check your current config:
```bash
pinner config
```
Inspect a specific key:
```bash
pinner config get base_endpoint
```
Reset if needed:
```bash
pinner setup --reset
```
## Still stuck?
1. Run `pinner doctor`; it reports CLI version, OS, config location and status, endpoint, memory limit, retry count, and authentication status
2. Search [existing issues](https://github.com/lumeweb/pinner-cli/issues)
3. Open a new issue with the error message, steps to reproduce, and `pinner doctor` output
# CIDs (Content Identifiers)
A CID is a unique address for a piece of content on IPFS. It's derived from the content itself; the same bytes always produce the same CID. Change a single byte and the CID changes completely.
CIDs are how IPFS finds content. Instead of asking "get this file from that server," you ask "get the content with this CID." Any node that has the content can serve it.
## CIDv0
The original format. CIDv0 always starts with `Qm` and supports only the DAG-PB codec (one content type). It is implicitly base58btc-encoded.
```
QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco
```
CIDv0 is still common but is considered a legacy format. Pinner accepts CIDv0 as input but immediately normalizes it to CIDv1 internally (see below).
## CIDv1
The modern format. CIDv1 includes a multicodec identifier so it can represent many content types, not just DAG-PB. It also carries an explicit multibase prefix, which means the same CIDv1 can appear in different text encodings. By convention, IPFS tools default to base32 encoding (lowercase), which is why CIDv1 strings typically start with `b`.
```
bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq
```
The first few characters of a base32 CIDv1 hint at its codec:
| Prefix | Codec | Example |
|--------|-------|---------|
| `bafybei…` | DAG-PB (0x70) | Content addressed as a protobuf MerkleDAG node |
| `bafkrei…` | Raw (0x55) | Raw leaf data, no DAG wrapping |
| `bafyrci…` | DAG-CBOR (0x71) | Content addressed as a CBOR-encoded IPLD node |
CIDv1 is more flexible, more self-describing, and works better with modern IPFS tools and gateways.
## How Pinner handles CIDs
Pinner always normalizes to CIDv1, even when you upload content that would have a CIDv0. This gives you:
* **One consistent format** across all your content
* **Better interoperability** with modern gateways and tools
* **Support for multiple content codecs** beyond just DAG-PB
The content itself doesn't change; only the way the address is written.
### Uploads default to CIDv1
When Pinner builds CAR files from your uploads, it uses CIDv1. The supported codecs are:
* **DAG-PB**: the default for files and directories uploaded through Pinner
* **Raw**: used for leaf data blocks
* **DAG-CBOR**: supported for IPLD nodes encoded as CBOR
### Known limitations
* **CIDv0 is not accepted as an IPNS target.** Because CIDv0 strings use the same base58btc multihash encoding as libp2p peer IDs, they can be confused with peer IDs. Use the CIDv1 equivalent instead.
* **CAR uploads must have a single root.** CAR files with zero or multiple roots are rejected. Most IPFS tools produce single-root CARs by default.
## Key mental model
* A CID is an address, not a URL. It doesn't tell you where the content is, only what it is.
* The same content always has the same CID, regardless of who pinned it or where.
* CIDv0 and CIDv1 can refer to the same content; they're just different ways to write the address. Pinner always stores and returns the CIDv1 form.
# Pin Lifecycle
When you pin content on Pinner, it goes through a defined sequence of states. Each state reflects what the system is doing with your content and what you can expect from it.
## States
A pin follows one of two paths depending on the upload method:
**Upload path** (file or CAR upload: status never enters `queued`):
```
pinning → pinned
↘ failed
```
**Remote pin path** (pin a CID that already exists on the network):
```
queued → pinning → pinned
↘ failed
```
### queued
You've requested a pin for a CID that already exists on the IPFS network. Pinner has recorded the request but hasn't started the pin workflow yet. The system allocates resources and prepares to fetch the content from the network. This is the default status for remote pin requests.
### pinning
The pin workflow is running. Depending on the upload method, Pinner is either processing uploaded blocks into the local blockstore (for file and CAR uploads) or downloading the content from the IPFS network and storing it (for remote pins of existing CIDs). This state covers everything from block ingestion, through quota validation and upload-record creation, until all operations complete successfully.
### pinned
Your content is stored in the local blockstore and is available for retrieval on the IPFS network. This is the steady state; a pin stays `pinned` until you explicitly delete it or replace it. Like all IPFS content, continued availability depends on at least one node holding the data.
### failed
Something went wrong during the pin workflow. Common reasons:
* The source CID doesn't exist or isn't reachable on the IPFS network
* The uploaded content could not be parsed (invalid CAR, corrupt file, unsupported format)
* Quota validation failed (upload or storage quota exceeded)
* Block-level storage errors prevented writing
The `info` field on a failed pin may contain additional diagnostic detail. You can retry by submitting a new pin request for the same CID.
## Transition triggers
| From | To | Trigger |
| --- | --- | --- |
| N/A | `queued` | Remote pin request (CID already on the network) |
| N/A | `pinning` | Upload request (file or CAR): goes directly to pinning, skipping `queued` |
| `queued` | `pinning` | Pin workflow starts: the system begins fetching the CID from the network |
| `pinning` | `pinned` | All workflow operations succeed: blocks stored, upload records created, core pin records created |
| `pinning` | `failed` | Any step in the workflow fails (download, parsing, quota, storage) |
## What happens during pinning
The pin workflow performs several steps regardless of upload method:
1. **Block processing**: content blocks are extracted and written to the local blockstore
2. **Quota validation**: upload and storage quotas are checked against the actual DAG size
3. **Upload and pin record creation**: an upload record and a core pin record are created for every block in the DAG
4. **Root pin creation**: an IPFS-specific pin record is created for the root CID, initially in `queued` status
5. **Status update to pinned**: the root pin's status is updated to `pinned` after all preceding steps complete
6. **DAG validation**: the system checks whether the new pin completes a partial DAG structure with related CIDs already pinned by the same user
## What happens after pinning
Once a pin reaches `pinned`, it stays there until you explicitly delete it or replace it. Pinner maintains the pin as long as your account is active and within quota. Content remains in the blockstore and available for retrieval.
## Replacing a pin
You can replace an existing pin with a new CID using the replace endpoint. This atomically deletes the old pin record and creates a new one in `queued` status, then starts the pin workflow for the new CID. The replacement pin goes through the same lifecycle as a new pin.
## What happens on unpin (delete)
When you delete a pin:
1. The IPFS pin record is **soft-deleted** from the database
2. Pinner checks if any other pin records for the same user still reference the same CID
3. If no other pins reference the CID, the core pin is removed, the underlying data is unpinned from the network, and associated file paths are cleaned up
4. If other pins still reference the CID (for example, multiple pins for the same content), only the pin record is removed; the data stays
This reference-counting approach means you can safely pin the same CID multiple times and delete individual pin records without affecting the remaining pins.
## No automatic replication
Pinner does not perform automatic multi-node replication. Content is written to the local blockstore and made available through the IPFS node. Availability on the broader network depends on the normal IPFS content routing and bitswap mechanisms; Pinner does not copy content to additional storage nodes.
# CAR Files
A CAR (Content Addressable Archive) is a container format for bundling IPFS blocks together. It's the native way to package and transfer content-addressed data in the IPFS ecosystem.
Pinner uses CAR files internally for **all uploads**: whether you upload a single file, a directory, an archive, or a pre-built CAR. Everything is converted to (or passed through as) a CAR file before reaching the server.
## What's inside a CAR file
A CAR file contains:
* **A header**: a varint-prefixed pragma (version number) followed by a varint-length-prefixed root CID list encoded in DAG-JSON
* **Blocks**: raw IPLD data blocks, each prefixed with their length and CID
```
┌─────────────────────────────────────┐
│ Header │
│ - varint: CAR version (1 or 2) │
│ - varint-prefixed DAG-JSON: │
│ { "roots": [CID, ...] } │
├─────────────────────────────────────┤
│ Block 1 │
│ - length (varint) │
│ - CID (content identifier) │
│ - block data (raw bytes) │
├─────────────────────────────────────┤
│ Block 2 ... │
└─────────────────────────────────────┘
```
### CAR versions
There are two CAR format versions:
* **CARv1**: the original format with a simple header and sequential blocks
* **CARv2**: adds an index trailer for random-access block lookup, wrapping a CARv1 payload
Pinner supports both CARv1 and CARv2 uploads. The server uses `go-car/v2` for reading, which handles both formats transparently.
### Single-root requirement
Pinner currently requires CAR files to have **exactly one root CID**. CAR files with multiple roots will be rejected. This is because a single pin record maps to one root CID; if your data has multiple logical roots, upload each as a separate CAR file.
## Why CAR files matter
* **Bulk uploads**: package many blocks into one file instead of uploading individually
* **Data portability**: move IPLD data between systems in a single transfer
* **Streaming**: read and write CAR files incrementally without loading everything into memory
* **Directory preservation**: maintain the full IPLD graph structure including directories
## The key advantage: pre-calculable CIDs
Because CAR files are made of raw IPFS blocks with their CIDs already computed, you know the root CID **before** you upload. This is impossible with non-IPFS formats like ZIP, where the server must extract, re-chunk, and re-encode the data before it can compute the CID.
## CAR files vs. archives
| | CAR Files | Archives (ZIP, tar) |
|--|-----------|---------------------|
| CID calculation | Client-side: you know it before uploading | Server-side: must wait for processing |
| CID known before upload | Yes | No |
| Processing time | Instant (blocks are already formed) | Requires server re-chunking and encoding |
| Pre-computation | Possible | Not possible |
| Deduplication | Block-level: each block is stored by CID | File-level only: entire archive treated as one blob |
With a CAR file, the CID is deterministic and locally verifiable. You're guaranteed the content will resolve to the same CID once pinned. With an archive, you must wait for the server to process it and then poll for the result.
## How Pinner handles CAR files
Pinner's upload pipeline works differently depending on whether you provide a pre-built CAR or a regular file:
### Pre-built CAR passthrough
If you upload a file that is already a valid CAR (detected by MIME type `application/vnd.ipld.car`, the `.car` extension, or the `isCarFile` option), Pinner skips preprocessing and uploads the CAR as-is. In the JS SDK, you can do this explicitly:
* `pinner.upload.raw(carFile)`: upload a pre-built CAR with the builder API
* `pinner.uploadCar(file, { isCarFile: true })`: direct CAR upload method
The server's `CARProcessor` validates the roots, checks quotas against the raw block data size (not the CAR file size which includes headers), and stores the file directly.
### Automatic CAR preprocessing
When you upload a regular file, directory, or stream, the JS SDK automatically converts it to CAR format before uploading. This happens client-side using Helia:
1. The input is chunked and encoded into UnixFS DAG blocks
2. A root CID is computed from the block graph
3. The result is streamed to the server as a CAR file
On the server, the upload is passed to `CARBlockProcessor`, which reads blocks individually from the CAR stream and stores each block by its CID, enabling **block-level deduplication** across all uploads.
### Block-level deduplication
Pinner deduplicates at the **block level**, not the file level. When a CAR file is processed, each block is stored by its CID. If the same block already exists in the blockstore (because it was part of a previous upload), it is not stored again. This means:
* Two different files that share common data blocks will only store those blocks once
* Re-uploading the same content is effectively free after the first upload
* CAR files inherently contain unique, deduplicated blocks; the server does not perform additional dedup checks during block processing because it would add overhead without benefit
## When to use CAR files
* You need the CID before the upload completes (deterministic builds, CI pipelines)
* You're bulk-transferring pre-packaged IPFS data
* You want to verify content integrity locally before trusting a remote service
* You're working with existing IPFS tooling that produces CAR output (e.g., `ipfs dag export`)
When you don't need these guarantees, a regular upload works just fine; Pinner converts your data into CAR format internally either way.
# Why No Public Gateway
Pinner is a pinning and hosting service, not a public anonymous IPFS gateway. We don't offer open, unauthenticated read access to arbitrary IPFS content. This is a deliberate choice.
## Fair pricing
Public anonymous gateways are expensive infrastructure with unpredictable bandwidth costs. Bundling gateway access into a pinning service forces every user to subsidize a feature they may not need. By treating them as separate concerns, you pay only for storage and pinning, and choose your own retrieval method.
## Sustainability
Serving arbitrary open web content through anonymous gateways carries significant liability and abuse potential. Making gateways profitable means pricing high enough to offset that risk, which isn't fair to users who just want their content pinned. Focusing on pinning keeps the service sustainable long-term.
## Focused purpose
Pinning and gateways serve fundamentally different needs. Pinning keeps your content available on the decentralized network. Gateways translate that content into HTTP for the traditional web. We excel at pinning; for gateway access, we offer [ipfs.pub](https://ipfs.pub), which serves your content over HTTP when you configure a domain through Pinner's website hosting.
## How ipfs.pub works
[ipfs.pub](https://ipfs.pub) is Pinner's DNSLink gateway. It's not an anonymous public gateway that serves arbitrary IPFS content; it specifically resolves `_dnslink` records for domains configured through Pinner's website hosting and serves the corresponding IPFS content over HTTP.
When you [host a website](/hosting/quickstart) on your own domain through Pinner, ipfs.pub is what actually serves it:
1. You point your domain's DNS at Pinner
2. Pinner configures a `_dnslink` record mapping your domain to your IPFS content
3. When a browser requests your domain, ipfs.pub resolves the `_dnslink` record and serves the content over HTTP
This means website hosting and ipfs.pub aren't separate things; **website hosting is how you configure ipfs.pub to serve your content on your domain**.
## What we also offer
* **[Website hosting](/hosting/quickstart)**: configure ipfs.pub to serve your IPFS content on your own domain with automatic SSL. This is the recommended way to make content accessible over HTTP.
* **[check.ipfs.pub](https://check.ipfs.pub)**: our deployment of [ipfs-check](https://check.ipfs.network), an IPFS content availability checker. Verify that your DNSLink domains and pinned content are retrievable from the IPFS network.
## What if Pinner goes away?
IPFS is content-addressed, not cloud storage. Your data is identified by its CID (a cryptographic hash), not locked behind a proprietary API. That means:
* **Your CIDs are portable.** The same CID works everywhere on IPFS. Pin your CIDs on another provider and your content is immediately preserved. No re-upload, no format conversion.
* **You can export your pin list at any time.** Pinner lets you download a complete list of your pinned CIDs, so you always know what to re-pin elsewhere.
* **Content pinning elsewhere survives.** If you run your own IPFS node, use another pinning service, or anyone else on the network has fetched and kept your content, it remains available.
The honest caveat: if Pinner is the **only** node holding your content and it becomes unavailable, that content becomes unreachable until it's pinned again or another node has a copy. This is true of any single pinning provider; it's the nature of IPFS. We recommend pinning important content with at least two providers, or running your own node as a backup.
That said, the architecture works in your favor: because IPFS is decentralized, there's no vendor lock-in at the data layer. Your content isn't stored in a proprietary format behind a proprietary API; it's on an open network with open addressing. Migration is a pin list, not a data migration.
## Accessing your content
Since Pinner doesn't include a public anonymous gateway, you retrieve your content through ipfs.pub (configured via website hosting), public gateways, local IPFS, or your own private gateway. See [Access Pinned Content](/ipfs/how-to/access-pinned-content) for practical options.
# SDK Architecture
The Pinner SDK is built around one core class: `Pinner`. Everything flows through it.
## The Pinner class
`Pinner` is the entry point. You create an instance with your configuration and then access all functionality through property-access namespaces:
* **`pinner.upload`**: upload files, directories, JSON, CAR data, and more
* **`pinner.pins`**: check status, update metadata, list and manage pins
* **`pinner.ipns`**: create and manage IPNS keys and publish records
* **`pinner.websites`**: publish content as a website with SSL support
Each namespace groups related operations together. You pass your JWT once in the configuration; the `Pinner` instance handles authentication for everything.
## Upload: callable namespace and builder
The `upload` property is unique: it works as both a direct method **and** a builder namespace. This is implemented via a JavaScript `Proxy` that delegates to whichever side you need:
* **As a method:** `pinner.upload(file, options)`: upload a file immediately
* **As a namespace:** `pinner.upload.file(file)`, `pinner.upload.json(data)`, and so on: returns a builder for chaining options
### Builder pattern
When you use the builder namespace, each content method returns a typed builder you chain options onto before calling `.pin()` to commit the upload:
1. Choose what to upload: `.file()`, `.json()`, `.base64()`, `.url()`, `.csv()`, `.text()`, `.raw()`
2. Optionally chain `.name()`, `.keyvalues()`, `.waitForOperation()`, or `.operationPollingOptions()`
3. Call `.pin()` to execute
This keeps the API composable; you only specify what you need, and each step returns a type-safe builder for the next.
### Convenience methods
Beyond the builder, `Pinner` also exposes shorthand methods for common workflows:
* **`uploadAndWait()`**: upload a file and wait for the result in one call
* **`waitForOperation()`**: poll an operation until it reaches a settled state
* **`uploadDirectory()`**: upload an array of files as a directory (preprocessed to CAR internally)
* **`uploadCar()`**: upload a raw CAR file without preprocessing
### Upload pipeline: XHR vs TUS
The `UploadManager` selects a transport strategy based on file size:
* **Small files** (below the account's upload limit, defaulting to 100 MB) use XHR upload
* **Large files** use TUS resumable upload
Both transports are built on Uppy internally. CAR files are automatically detected by MIME type or extension, or can be forced via the `isCarFile` option.
## Pinata adapters
Pinner ships with two adapter functions that wrap a `Pinner` instance in a Pinata SDK-compatible interface:
* **`pinataAdapter`**: matches Pinata SDK 2.x API shape (namespaced with `upload.public`, `files.public`, etc.)
* **`pinataLegacyAdapter`**: matches Pinata SDK 1.x API shape (flat methods like `pinFileToIPFS`, `pinJSONToIPFS`)
The adapters translate Pinata-style calls into Pinner operations behind the scenes. Features that Pinner does not support (groups, analytics, private uploads, signed URLs) return empty data or throw a "not supported" error. Your existing Pinata code can migrate with only the initialization changed.
## Error hierarchy
Errors in the SDK follow a single-root hierarchy so you can catch at the granularity you need:
* **`PinnerError`**: base class for all SDK errors; carries a `code`, a `retryable` flag, and an optional `cause`
* **`ConfigurationError`**: missing or invalid configuration (not retryable)
* **`AuthenticationError`**: JWT or access failure (not retryable)
* **`EnvironmentError`**: unsupported runtime environment (not retryable)
* **`UploadError`**: any upload failure
* **`NetworkError`**: request-level failure (retryable)
* **`ValidationError`**: invalid input; includes an optional `field`
* **`EmptyFileError`**: zero-byte file upload attempt
* **`TimeoutError`**: request exceeded its deadline (retryable)
* **`PinError`**: any pin-management failure
* **`NotFoundError`**: pin or resource does not exist (not retryable)
* **`RateLimitError`**: too many requests; includes `retryAfter` seconds (retryable)
The `retryable` flag on `PinnerError` lets you decide programmatically whether to retry. The SDK also exports type-guard functions: `isRetryable(error)` and `isAuthenticationError(error)` for common checks.
## Key design principles
* **One entry point.** Create a `Pinner` instance once, use it everywhere.
* **Dual-mode namespace.** `upload` is both a direct method and a builder namespace, via a `Proxy`.
* **Builder pattern for uploads.** Start with a content method, chain options, commit with `.pin()`.
* **Automatic transport selection.** XHR for small uploads, TUS for large; transparent to the caller.
* **Adapter compatibility.** Pinata SDK 2.x and 1.x drop-in replacements for easy migration.
# Getting Started
Set up the Pinner SDK and pin your first file in about five minutes.
## Prerequisites
* An API key: [create one in your account](/account/api-tokens)
* Node.js 20+ or a modern browser
## Install the package
:::code-group
```bash [npm]
npm install @lumeweb/pinner
```
```bash [pnpm]
pnpm add @lumeweb/pinner
```
:::
## Initialize Pinner
The SDK needs an API key to authenticate. In a browser, pass it directly. On the server, read it from the environment.
:::code-group
```typescript [Browser]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({
jwt: "YOUR_JWT"
});
```
```typescript [Node.js]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({
jwt: process.env.PINNER_AUTH_TOKEN
});
```
:::
## Upload your first file
`uploadAndWait` pins the file and waits for the pin to settle. It returns an `UploadResult` with the CID (may be `undefined` for TUS uploads until the server assigns it), name, size, and more.
:::code-group
```typescript [Browser]
const file = new File(["Hello, Pinner!"], "hello.txt", { type: "text/plain" });
const result = await pinner.uploadAndWait(file);
console.log("CID:", result.cid);
```
```typescript [Node.js]
import { readFileSync } from "fs";
// Node.js 20+ provides the global File class
const buffer = readFileSync("./hello.txt");
const file = new File([buffer], "hello.txt", { type: "text/plain" });
const result = await pinner.uploadAndWait(file);
console.log("CID:", result.cid);
```
:::
`uploadAndWait` handles the full cycle: it uploads the file, waits for pinning to complete, and returns an `UploadResult` with the CID (may be `undefined` for TUS uploads until the server assigns it), name, size, and more.
## Verify the connection
Upload a small file and confirm you get a CID back. This proves both authentication and connectivity work end-to-end.
```typescript
const file = new File(["ping"], "ping.txt", { type: "text/plain" });
const result = await pinner.uploadAndWait(file);
if (result.cid) {
console.log("Connected! CID:", result.cid);
} else {
console.log("Upload sent, operation ID:", result.id);
}
```
## Type your config
If you prefer explicit types, import `PinnerConfig`:
:::code-group
```typescript [Browser]
import { Pinner, PinnerConfig } from "@lumeweb/pinner";
const config: PinnerConfig = {
jwt: "YOUR_JWT",
endpoint: "https://ipfs.pinner.xyz"
};
const pinner = new Pinner(config);
```
```typescript [Node.js]
import { Pinner, PinnerConfig } from "@lumeweb/pinner";
const config: PinnerConfig = {
jwt: process.env.PINNER_AUTH_TOKEN,
endpoint: "https://ipfs.pinner.xyz"
};
const pinner = new Pinner(config);
```
:::
***
Ready to customize? See [Configuration](/ipfs/sdk/configuration) for endpoints, gateways, storage backends, and environment-specific setup.
# Configuration
The `PinnerConfig` interface controls how the SDK connects, where it stores local data, and which gateway it uses to retrieve content.
## PinnerConfig interface
```typescript
interface PinnerConfig {
/** JWT authentication token. Required. */
jwt: string;
/** API endpoint. Defaults to "https://ipfs.pinner.xyz". */
endpoint?: string;
/** IPFS gateway for content retrieval. Defaults to "https://inbrowser.link". */
gateway?: string;
/** Allowed MIME types. If omitted, all types are accepted. */
allowedFileTypes?: string[];
/** Custom fetch implementation. */
fetch?: typeof fetch;
/** Custom datastore for Helia. Highest storage priority. */
datastore?: Datastore;
/** Custom storage instance for Helia blockstore and datastore. */
storage?: Storage;
/** Base name for default Helia storage. Only used when neither datastore nor storage are provided. Defaults to "pinner-helia-data". */
datastoreName?: string;
/** Upload request timeout in milliseconds. Applied to XHR uploads (TUS uses retryDelays instead). Defaults to 120_000. */
timeout?: number;
/** Number of retry attempts for failed uploads. Applied to XHR uploads (TUS uses retryDelays instead). Defaults to 3. */
retries?: number;
}
```
The interface is the same in browser and Node; only the values you pass differ.
## Endpoint
By default the SDK talks to `https://ipfs.pinner.xyz`. Point it at a different host for staging or self-hosted instances:
:::code-group
```typescript [Browser]
const pinner = new Pinner({
jwt: "YOUR_JWT",
endpoint: "https://staging.ipfs.pinner.xyz"
});
```
```typescript [Node.js]
const pinner = new Pinner({
jwt: process.env.PINNER_AUTH_TOKEN,
endpoint: "https://staging.ipfs.pinner.xyz"
});
```
:::
## Gateway
The gateway URL is used when the SDK resolves CIDs to retrievable HTTP URLs. The default is `https://inbrowser.link`.
For Pinner-pinned content, `https://ipfs.pub` offers lower latency since it serves from the same infrastructure that stores your pins:
:::code-group
```typescript [Browser]
const pinner = new Pinner({
jwt: "YOUR_JWT",
gateway: "https://ipfs.pub"
});
```
```typescript [Node.js]
const pinner = new Pinner({
jwt: process.env.PINNER_AUTH_TOKEN,
gateway: "https://inbrowser.link"
});
```
:::
## Custom fetch
Pass your own `fetch` when you need request interceptors, custom headers, or a non-global HTTP client.
:::code-group
```typescript [Browser]
const pinner = new Pinner({
jwt: "YOUR_JWT",
fetch: window.fetch
});
```
```typescript [Node.js]
import { fetch } from "undici";
const pinner = new Pinner({
jwt: process.env.PINNER_AUTH_TOKEN,
fetch
});
```
:::
## Storage configuration
The SDK uses Helia under the hood. Storage is resolved in priority order:
| Priority | Option | What it does |
|----------|--------|--------------|
| 1 (highest) | `datastore` | Custom `Datastore` instance, used directly by Helia. |
| 2 | `storage` | Custom `unstorage` instance applied to both blockstore and datastore. |
| 3 | `datastoreName` | Custom base name for the default storage driver. Only used when neither `datastore` nor `storage` are provided. |
| 4 (default) | *(none)* | Uses `"pinner-helia-data"` as the base name. |
### Browser: storage levels
```typescript
// Level 1: default (IndexedDB with base "pinner-helia-data")
const pinner = new Pinner({ jwt: "YOUR_JWT" });
// Level 2: custom base name
const pinner = new Pinner({
jwt: "YOUR_JWT",
datastoreName: "my-app-storage"
});
// Level 3: custom storage instance
import { createStorage } from "unstorage";
import indexeddbDriver from "unstorage/drivers/indexedb";
const storage = createStorage({
driver: indexeddbDriver({ base: "my-app" })
});
const pinner = new Pinner({ jwt: "YOUR_JWT", storage });
// Level 4: custom datastore (highest priority)
import { MemoryDatastore } from "interface-datastore";
const datastore = new MemoryDatastore();
const pinner = new Pinner({ jwt: "YOUR_JWT", datastore });
```
### Node.js: storage levels
```typescript
// Level 1: default (filesystem in ./.pinner-*)
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
// Level 2: custom base name
const pinner = new Pinner({
jwt: process.env.PINNER_AUTH_TOKEN,
datastoreName: "my-app-storage"
});
// Level 3: custom storage instance
import { createStorage } from "unstorage";
import fsDriver from "unstorage/drivers/fs";
const storage = createStorage({
driver: fsDriver({ base: "./my-storage" })
});
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN, storage });
// Level 4: custom datastore (highest priority)
import { MemoryDatastore } from "interface-datastore";
const datastore = new MemoryDatastore();
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN, datastore });
```
## Timeout
By default, XHR uploads time out after 120 000 ms (2 minutes). Pass a custom `timeout` value in milliseconds to increase or decrease the limit. Note that TUS uploads do not use this option; they rely on their own `retryDelays` mechanism instead.
:::code-group
```typescript [Browser]
const pinner = new Pinner({
jwt: "YOUR_JWT",
timeout: 300_000 // 5 minutes
});
```
```typescript [Node.js]
const pinner = new Pinner({
jwt: process.env.PINNER_AUTH_TOKEN,
timeout: 300_000 // 5 minutes
});
```
:::
## Retries
The number of retry attempts for failed XHR uploads. Defaults to `3`. As with `timeout`, this only applies to XHR uploads; TUS uploads use their own retry mechanism.
:::code-group
```typescript [Browser]
const pinner = new Pinner({
jwt: "YOUR_JWT",
retries: 5
});
```
```typescript [Node.js]
const pinner = new Pinner({
jwt: process.env.PINNER_AUTH_TOKEN,
retries: 5
});
```
:::
## Environment-specific setup
Keep configuration out of your codebase. Use environment variables and separate config modules for dev versus prod.
```typescript title="config/development.ts"
export const config = {
jwt: process.env.DEV_PINNER_AUTH_TOKEN,
endpoint: "https://staging.ipfs.pinner.xyz"
};
```
```typescript title="config/production.ts"
export const config = {
jwt: process.env.PROD_PINNER_AUTH_TOKEN,
endpoint: "https://ipfs.pinner.xyz"
};
```
Feed the config object straight into `new Pinner(config)` and the SDK picks the right endpoint without branching logic in your app code.
# SDK Error Handling
The SDK throws structured error classes that extend a common `PinnerError` base. Every error carries a machine-readable `code`, a human-readable `message`, a `retryable` flag, and an optional `cause`.
## Error class hierarchy
```
PinnerError
├── ConfigurationError
├── AuthenticationError
├── UploadError
│ ├── NetworkError
│ ├── ValidationError
│ │ └── EmptyFileError
│ └── TimeoutError
├── PinError
│ ├── NotFoundError
│ └── RateLimitError
└── EnvironmentError
```
## Error classes
### `PinnerError`
The base class for all SDK errors. Every subclass inherits these properties:
| Property | Type | Description |
|----------|------|-------------|
| `code` | `string` | Machine-readable error code |
| `message` | `string` | Human-readable error description |
| `retryable` | `boolean` | Whether the operation can be retried |
| `cause` | `Error \| undefined` | The underlying error that caused this one |
### `ConfigurationError`
Thrown when the SDK is misconfigured (e.g. missing JWT token).
* **Code:** `CONFIGURATION_ERROR`
* **Retryable:** `false`
### `AuthenticationError`
Thrown for HTTP 401 and 403 responses when a request fails authentication or authorization.
* **Code:** `AUTHENTICATION_ERROR`
* **Retryable:** `false`
### `UploadError`
Base class for upload-related errors.
* **Code:** `UPLOAD_ERROR`
* **Retryable:** set per subclass
### `NetworkError`
Thrown when a network request fails (HTTP errors that don't map to a more specific class, or non-HTTP network failures).
* **Code:** `NETWORK_ERROR`
* **Retryable:** `true`
### `ValidationError`
Thrown for HTTP 400 responses (invalid request data). Also used for HTTP 410 in the websites API (target is broken or gone).
* **Code:** `VALIDATION_ERROR`
* **Retryable:** `false`
* **Extra property:** `field?: string`, the field that failed validation
### `EmptyFileError`
Thrown when attempting to upload an empty file or stream. Extends `ValidationError` with `field` set to `"file"`.
* **Code:** `EMPTY_FILE_ERROR`
* **Retryable:** `false`
### `TimeoutError`
Thrown when a request times out.
* **Code:** `TIMEOUT_ERROR`
* **Retryable:** `true`
### `PinError`
Base class for pin operation errors.
* **Code:** `PIN_ERROR`
* **Retryable:** set per subclass
### `NotFoundError`
Thrown for HTTP 404 responses or when a pin is not found for a given CID.
* **Code:** `NOT_FOUND`
* **Retryable:** `false`
### `RateLimitError`
Thrown for HTTP 429 rate-limit responses.
* **Code:** `RATE_LIMIT_EXCEEDED`
* **Retryable:** `true`
* **Extra property:** `retryAfter?: number`, seconds to wait before retrying
### `EnvironmentError`
Thrown when the runtime environment is not supported.
* **Code:** `ENVIRONMENT_ERROR`
* **Retryable:** `false`
## HTTP-to-error mapping
The SDK maps HTTP status codes to error classes as follows:
| HTTP status | Error class | Notes |
|------------|-------------|-------|
| 400 | `ValidationError` | Invalid request data |
| 401 | `AuthenticationError` | Bad or expired JWT |
| 403 | `AuthenticationError` | No permission for this resource |
| 404 | `NotFoundError` | Resource doesn't exist |
| 410 | `ValidationError` | Target is broken or gone (websites API) |
| 429 | `RateLimitError` | Too many requests; wait `retryAfter` seconds then retry |
| Other HTTP errors | `NetworkError` | Generic HTTP failure |
| Network failures | `NetworkError` | No response received |
## Retryability
Each error class sets the `retryable` flag:
| Error class | `retryable` |
|------------|-------------|
| `NetworkError` | `true` |
| `TimeoutError` | `true` |
| `RateLimitError` | `true` |
| All others | `false` |
## Type guards
The SDK exports two type guards from `@lumeweb/pinner`:
```typescript
import { isRetryable, isAuthenticationError } from "@lumeweb/pinner";
// Check if an error can be retried
if (isRetryable(error)) {
// wait and retry
}
// Check if an error is authentication-related
if (isAuthenticationError(error)) {
// prompt user to re-authenticate
}
```
You can also use `instanceof` checks directly:
```typescript
import { NotFoundError, RateLimitError } from "@lumeweb/pinner";
if (error instanceof NotFoundError) {
// handle 404
}
if (error instanceof RateLimitError) {
console.log(`Retry after ${error.retryAfter} seconds`);
}
```
## Example: Handling errors
```typescript
import {
Pinner,
AuthenticationError,
EmptyFileError,
RateLimitError,
isRetryable,
} from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
try {
const result = await pinner.upload(file);
} catch (error) {
if (error instanceof AuthenticationError) {
console.error(
"Bad or expired API key. Re-authenticate and try again."
);
} else if (error instanceof EmptyFileError) {
console.error("Cannot upload an empty file.");
} else if (error instanceof RateLimitError) {
console.error(
`Rate limit exceeded. Retry after ${error.retryAfter ?? 60} seconds.`
);
} else if (error instanceof Error && isRetryable(error as any)) {
console.error("Transient error; retry:", error.message);
} else {
console.error("Upload failed:", error);
}
}
```
## See also
* [Error Reference](/ipfs/reference/errors): complete list of API error codes
* [CLI Troubleshooting](/ipfs/how-to/troubleshoot-uploads): common upload errors and fixes
# Upload Progress & Events
`pinner.upload()` returns a `Promise` that gives you real-time progress, pause/resume control, and a promise that resolves when the upload finishes.
## UploadOperation
:::code-group
```typescript [Browser]
const pinner = new Pinner({ jwt: "YOUR_JWT" });
const operation = await pinner.upload(file);
// Wait for the upload to finish
const result = await operation.result;
console.log("CID:", result.cid);
console.log("Operation ID:", result.operationId);
// Control the upload
operation.pause(); // toggles pause state (TUS uploads only)
operation.resume(); // toggles pause state (TUS uploads only)
operation.cancel(); // aborts the upload
```
```typescript [Node.js]
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
const operation = await pinner.upload(file);
// Wait for the upload to finish
const result = await operation.result;
console.log("CID:", result.cid);
console.log("Operation ID:", result.operationId);
// Control the upload
operation.pause(); // toggles pause state (TUS uploads only)
operation.resume(); // toggles pause state (TUS uploads only)
operation.cancel(); // aborts the upload
```
:::
### UploadOperation API
| Member | Type | Description |
|--------|------|-------------|
| `result` | `Promise` | Resolves with the CID, operation ID, and upload metadata |
| `progress` | `Readonly` | Snapshot of the current progress |
| `cancel()` | `() => void` | Abort the upload |
| `pause()` | `() => void` | Toggle pause on a TUS upload |
| `resume()` | `() => void` | Toggle resume on a paused TUS upload |
:::warning
`pause()` and `resume()` both toggle the Uppy pause state; they are not independently scoped. Only TUS uploads support pause/resume; XHR uploads ignore these calls.
:::
## Progress events
Pass an `onProgress` callback in `UploadOptions` to get notified as bytes transfer:
:::code-group
```typescript [Browser]
const pinner = new Pinner({ jwt: "YOUR_JWT" });
const operation = await pinner.upload(file, {
onProgress: (progress) => {
console.log(`Progress: ${progress.percentage.toFixed(2)}%`);
console.log(`Uploaded: ${progress.bytesUploaded} / ${progress.bytesTotal} bytes`);
if (progress.speed != null) {
console.log(`Speed: ${progress.speed} bytes/sec`);
}
if (progress.eta != null) {
console.log(`ETA: ${progress.eta} seconds`);
}
}
});
const result = await operation.result;
console.log("Complete:", result.cid);
```
```typescript [Node.js]
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
const operation = await pinner.upload(file, {
onProgress: (progress) => {
console.log(`Progress: ${progress.percentage.toFixed(2)}%`);
console.log(`Uploaded: ${progress.bytesUploaded} / ${progress.bytesTotal} bytes`);
if (progress.speed != null) {
console.log(`Speed: ${progress.speed} bytes/sec`);
}
if (progress.eta != null) {
console.log(`ETA: ${progress.eta} seconds`);
}
}
});
const result = await operation.result;
console.log("Complete:", result.cid);
```
:::
### UploadProgress properties
| Property | Type | Description |
|----------|------|-------------|
| `percentage` | `number` | Upload progress from 0 to 100 |
| `bytesUploaded` | `number` | Bytes transferred so far |
| `bytesTotal` | `number` | Total bytes to transfer |
| `speed` | `number \| undefined` | Current speed in bytes per second (optional) |
| `eta` | `number \| undefined` | Estimated seconds remaining (optional) |
## Build a progress bar
Wire `onProgress` into any UI framework. A plain-DOM example:
```typescript
const bar = document.getElementById("progress-bar");
const operation = await pinner.upload(file, {
onProgress: (progress) => {
bar.style.width = `${progress.percentage}%`;
bar.textContent = `${progress.percentage.toFixed(0)}%`;
}
});
const result = await operation.result;
bar.textContent = `Done: ${result.cid}`;
```
In React, store `progress.percentage` in state and render it the same way.
## Cancel an upload
Call `cancel()` on the operation to abort an in-flight upload:
```typescript
const operation = await pinner.upload(file);
// Some time later: user clicked "Cancel" or a timeout fired
operation.cancel();
try {
await operation.result; // rejects after cancel
} catch (error) {
console.log("Upload was cancelled");
}
```
After `cancel()`, the `result` promise rejects. Catch it to clean up UI state.
## waitForOperation
Uploading is only the first step; the pin must also settle on the network. `waitForOperation` polls until the pin reaches a settled state.
:::code-group
```typescript [Browser]
const pinner = new Pinner({ jwt: "YOUR_JWT" });
const operation = await pinner.upload(file);
const uploadResult = await operation.result;
// Wait for pinning to complete
const result = await pinner.waitForOperation(uploadResult, {
interval: 1000, // poll every 1s
timeout: 60000 // give up after 60s
});
console.log("CID:", result.cid);
console.log("Operation ID:", result.operationId);
```
```typescript [Node.js]
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
const operation = await pinner.upload(file);
const uploadResult = await operation.result;
// Wait for pinning to complete
const result = await pinner.waitForOperation(uploadResult, {
interval: 1000, // poll every 1s
timeout: 60000 // give up after 60s
});
console.log("CID:", result.cid);
console.log("Operation ID:", result.operationId);
```
:::
### Input types
`waitForOperation` accepts either an `UploadResult` or a numeric operation ID:
```typescript
// Recommended: pass the full UploadResult
// Uses uploadResult.operationId if present, otherwise looks up by CID
const result = await pinner.waitForOperation(uploadResult);
// Also works: pass just the operation ID (number)
const result = await pinner.waitForOperation(uploadResult.operationId!);
```
:::info
When you pass an `UploadResult`, `waitForOperation` uses the `operationId` field directly if present. If `operationId` is missing but `cid` is available, it looks up the operation by CID first. This means even uploads that don't return an operation ID can still be awaited.
:::
### Polling options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `interval` | `number` | `2000` | Polling interval in milliseconds |
| `timeout` | `number` | `300000` | Maximum wait time in milliseconds (5 minutes) |
| `settledStates` | `string[]` | `["completed", "failed", "error"]` | States that count as settled |
***
Next: [Error Handling](/ipfs/sdk/errors): classify and recover from SDK errors.
# Getting Started with the CLI
Install the Pinner CLI, walk through the setup wizard, and confirm everything works.
If you just want to pin a file right now, see [Quickstart](/ipfs/quickstart).
## Install
:::code-group
```bash [macOS / Linux]
curl -fsSL https://get.pinner.xyz | sh
```
```powershell [Windows]
irm https://get.pinner.xyz/install.ps1 | iex
```
:::
The installer detects your package manager and uses it automatically. On macOS it prefers Homebrew; on Linux it tries `dpkg` then `rpm` before falling back to a binary download. On Windows it tries winget, then Scoop, then falls back to a binary download. All methods verify SHA256 checksums.
### Other install methods
The universal installer handles most cases. Use these when you need more control.
| Method | Command |
|--------|---------|
| Homebrew | `brew install lumeweb/tap/pinner` |
| winget | `winget install Pinner.Cli` |
| Scoop | `scoop bucket add lumeweb https://github.com/LumeWeb/scoop-bucket` then `scoop install pinner` |
| Debian/Ubuntu | `sudo dpkg -i pinner-cli_VERSION_linux_amd64.deb` |
| Fedora/RHEL | `sudo rpm -i pinner-cli_VERSION_linux_amd64.rpm` |
| Go | `go install go.lumeweb.com/pinner-cli/cmd/pinner@latest` |
| Binary | Download from [GitHub Releases](https://github.com/LumeWeb/pinner-cli/releases) |
Installer flags
**macOS / Linux** (`install.sh`):
| Flag | Description |
|------|-------------|
| `--system` | Install to `/usr/local/bin` (requires sudo if not writable) |
| `--bin-dir DIR` | Install to a custom directory |
| `--arch ARCH` | Override detected architecture (`amd64` or `arm64`) |
| `--no-pkg` | Skip package manager detection, use binary install |
| `--uninstall` | Remove the Pinner CLI |
| `--debug` | Enable verbose output |
**Windows** (`install.ps1`):
| Flag | Description |
|------|-------------|
| `-System` | Install to Program Files (requires admin) |
| `-NoPkg` | Skip winget/scoop detection |
| `-CI` | Enable CI mode (also activated by `CI=true` env var) |
| `-Uninstall` | Remove the Pinner CLI |
## Set up
After installing, run the setup wizard:
```bash
pinner setup
```
The wizard walks you through:
* **Authentication**: log in or register
* **Configuration**: set API endpoint, memory limits, and other options
If you've already authenticated or configured, the wizard detects that and skips the step.
### Skip steps
```bash
# Skip authentication (already logged in)
pinner setup --skip-auth
# Skip configuration (already configured)
pinner setup --skip-config
# Reset everything and start fresh
pinner setup --reset
```
### Non-interactive environments
For CI, Docker, or scripts, skip the wizard and use individual commands:
```bash
# Authenticate with a JWT token
pinner auth YOUR_TOKEN
# Or log in interactively
pinner auth --email you@example.com
# Configure
pinner config set memory_limit 256
pinner config set secure true
```
## Verify
```bash
# Print version
pinner --version
# Display diagnostic information for troubleshooting
pinner doctor
```
If `pinner doctor` reports problems, see [CLI Doctor](/ipfs/cli/doctor) for troubleshooting.
## Next steps
* [Upload your first file](/ipfs/quickstart)
* [Configure CLI settings](/ipfs/cli/config)
* [Manage API keys](/ipfs/cli/auth)
# CLI Auth
The CLI handles API key creation automatically. You don't need to visit the dashboard to generate keys; `pinner auth` creates them for you.
## Log in
### Interactive (recommended for local development)
```bash
pinner auth
```
Prompts for email and password. The CLI authenticates, generates an API key, and stores it locally.
### With email, prompt for password
```bash
pinner auth --email you@example.com
# or with the short flag
pinner auth -e you@example.com
```
### Provide an existing token (CI/CD)
If you already have a JWT auth token (for example, from another machine), pass it directly:
```bash
pinner auth YOUR_JWT_TOKEN
```
The CLI stores it locally for future use. This is the non-interactive path; use it for CI/CD pipelines and scripts.
### Non-interactive (CI / scripts)
```bash
# Email and password from flags
pinner auth --email you@example.com --password yourpassword
# Email and password from environment variables
PINNER_EMAIL=you@example.com PINNER_PASSWORD=yourpassword pinner auth
# With 2FA
pinner auth --email you@example.com --password yourpassword --otp-code 123456
# With 2FA from environment variables
PINNER_EMAIL=you@example.com PINNER_PASSWORD=yourpassword PINNER_OTP=123456 pinner auth
# Password via stdin (more secure than --password flag)
echo "yourpassword" | pinner auth --email you@example.com
```
### Named keys
Give your key a name to track which machine or environment it belongs to:
```bash
pinner auth --email you@example.com --key-name "ci-pipeline"
# or with the short flag
pinner auth -e you@example.com -k "ci-pipeline"
```
If not specified, keys are named `cli-generated` by default.
### Skip API key creation
To save the login token directly without creating an API key:
```bash
pinner auth --no-create-key
```
### Overwrite existing credentials
To overwrite an existing auth token without a confirmation prompt:
```bash
pinner auth --force
```
## Register
Create a new Pinner account from the CLI:
```bash
# Interactive: prompts for all fields
pinner register
# Non-interactive
pinner register --email you@example.com --first-name Jane --last-name Doe
# Provide password non-interactively
pinner register --email you@example.com --first-name Jane --last-name Doe --password yourpassword
```
The `register` command also supports short flags: `-e` (email), `-f` (first-name), `-l` (last-name), `-p` (password).
After registering, confirm your email:
```bash
pinner confirm-email --email you@example.com --token abc123def456
# or with short flags
pinner confirm-email -e you@example.com -t abc123def456
```
## Check authentication status
```bash
pinner auth status
```
Verifies that your stored auth token is valid by making a request to the Pinner.xyz API.
## Key management
The CLI creates API keys automatically when you run `pinner auth`. You can also manage keys directly from the command line.
### List keys
```bash
pinner account api-keys list
# Filter by name
pinner account api-keys list --search my-key
```
### Create a key
```bash
pinner account api-keys create my-key-name
```
The key token is displayed once; save it securely, as it cannot be retrieved later.
### Delete a key
```bash
pinner account api-keys delete my-key-name
# or by UUID
pinner account api-keys delete 00000000-0000-0000-0000-000000000001
# Force deletion of the currently active key
pinner account api-keys delete my-key-name --force
```
If you delete the key currently used for authentication, you must re-authenticate with `pinner auth`.
### Rotate a key
```bash
# 1. Create a new named key
pinner auth --email you@example.com --key-name "rotation-$(date +%Y%m%d)"
# 2. Update your environment variables and deployments with the new key
# 3. Delete the old key
pinner account api-keys delete old-key-name
```
## 2FA / OTP
Secure your account with one-time passwords:
```bash
# Enable 2FA (interactive)
pinner account otp enable
# Enable 2FA non-interactively
pinner account otp enable --otp 123456
# Disable 2FA
pinner account otp disable
# Disable 2FA non-interactively
pinner account otp disable --password mypassword
```
When 2FA is active, include your OTP code when authenticating:
```bash
PINNER_EMAIL=you@example.com PINNER_PASSWORD=*** PINNER_OTP=123456 pinner auth
```
## Flags reference
### `pinner auth`
| Flag | Short | Description | Env var |
|------|-------|-------------|---------|
| `--email` | `-e` | Email address for login | `PINNER_EMAIL` |
| `--password` | `-p` | Password for login (insecure; prefer stdin or env var) | `PINNER_PASSWORD` (also reads from stdin) |
| `--otp-code` | `-o` | OTP code for 2FA (6 digits) | `PINNER_OTP` |
| `--key-name` | `-k` | Custom name for created API key (default: `cli-generated`) | N/A |
| `--no-create-key` | N/A | Skip API key creation, save token directly | N/A |
| `--force` | N/A | Overwrite existing auth token without confirmation | N/A |
## Environment variables
| Environment variable | Purpose | Used by |
|---------------------|---------|---------|
| `PINNER_AUTH_TOKEN` | Your auth token (JWT), used by all commands | CLI, SDK |
| `PINNER_EMAIL` | Email address for `pinner auth` | CLI |
| `PINNER_PASSWORD` | Password for `pinner auth` | CLI |
| `PINNER_OTP` | OTP code for 2FA during `pinner auth` | CLI |
The SDK and CLI check `PINNER_AUTH_TOKEN` when you don't pass a token directly. The email, password, and OTP env vars are only used by `pinner auth`.
# CLI Config
View and change CLI configuration from the command line.
## Usage
```bash
# Show all configuration values
pinner config
# Get a specific value
pinner config get
# Set a value
pinner config set
```
## Show all configuration
```bash
pinner config
```
Displays all configuration values in a table with Key, Value, Env, and Description columns.
## Get a value
```bash
pinner config get base_endpoint
pinner config get secure
```
## Set a value
```bash
# Change the base endpoint
pinner config set base_endpoint "pinner.xyz"
# Disable HTTPS
pinner config set secure false
# Increase retry attempts
pinner config set max_retries 5
# Set memory limit for CAR generation (MB)
pinner config set memory_limit 256
# Set IPFS gateway override
pinner config set gateway_endpoint "https://inbrowser.link"
# Set default API timeout
pinner config set default_timeout 60s
# Preview a change without saving
pinner config set memory_limit 256 --dry-run
```
## Configuration keys
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `base_endpoint` | string | `""` | Base URL for Pinner service (e.g., `pinner.xyz`). Subdomains are derived automatically (e.g., `account.pinner.xyz`, `ipfs.pinner.xyz`). Leave empty for default. |
| `secure` | bool | `true` | Use HTTPS for endpoints |
| `max_retries` | int | `3` | Maximum retry attempts for failed operations (0–10) |
| `memory_limit` | int | `100` | Memory limit in MB for CAR generation (1–10240) |
| `gateway_endpoint` | string | `""` | IPFS gateway URL (e.g., `https://inbrowser.link`). Falls back to the `ipfs` subdomain of the base endpoint. |
| `default_timeout` | duration | `30s` | Default timeout for API operations (e.g., `30s`, `1m`) |
| `upload_timeout` | duration | `5m` | Timeout for upload, download, and benchmark operations (e.g., `5m`, `10m`) |
| `sync_timeout` | duration | `1m` | Timeout for reconciliation, cleanup, and sync operations (e.g., `1m`, `2m`) |
| `auth_token` | string | `""` | JWT authentication token for API access. Managed by `pinner auth`. |
## Environment variables
Every configuration key can also be set via an environment variable with the `PINNER_` prefix, uppercased and with hyphens replaced by underscores:
```bash
# Examples
export PINNER_BASE_ENDPOINT="pinner.xyz"
export PINNER_SECURE=false
export PINNER_MAX_RETRIES=5
export PINNER_MEMORY_LIMIT=256
export PINNER_GATEWAY_ENDPOINT="https://inbrowser.link"
export PINNER_AUTH_TOKEN="your-token"
```
| Config key | Environment variable |
|------------|----------------------|
| `base_endpoint` | `PINNER_BASE_ENDPOINT` |
| `secure` | `PINNER_SECURE` |
| `max_retries` | `PINNER_MAX_RETRIES` |
| `memory_limit` | `PINNER_MEMORY_LIMIT` |
| `gateway_endpoint` | `PINNER_GATEWAY_ENDPOINT` |
| `default_timeout` | `PINNER_DEFAULT_TIMEOUT` |
| `upload_timeout` | `PINNER_UPLOAD_TIMEOUT` |
| `sync_timeout` | `PINNER_SYNC_TIMEOUT` |
| `auth_token` | `PINNER_AUTH_TOKEN` |
## Configuration file
The CLI stores configuration in a YAML file on disk. The auth token is managed separately by `pinner auth`.
| Platform | Path |
|----------|------|
| Linux / macOS | `~/.config/pinner/config.yaml` |
## Options
| Option | Description |
|--------|-------------|
| `--dry-run` | Preview changes without saving |
## Subcommands
| Subcommand | Description |
|------------|-------------|
| *(none)* | Show all configuration values |
| `get ` | Get a specific configuration value |
| `set ` | Set a configuration value |
# CLI Setup
Use the interactive setup wizard to authenticate and configure the CLI in one go.
```bash
pinner setup
```
The wizard walks you through four steps:
* **Authentication**: create an account, sign in, or skip
* **Configuration**: set API endpoint and HTTPS preferences, or use defaults
* **Shell Completion**: enable auto-completion for your shell
* **Quick Tutorial**: browse common commands and examples
If you've already authenticated, the wizard detects your existing auth token and skips the authentication step. Similarly, the configuration step is skipped if a custom endpoint is already set (unless `--reset` is used).
## Skip steps
```bash
# Skip authentication (already logged in)
pinner setup --skip-auth
# Skip configuration (already configured)
pinner setup --skip-config
# Skip both: use when you only want to run the completion and tutorial steps
pinner setup --skip-auth --skip-config
```
## Reset configuration
Start fresh: clears your config and runs the wizard from scratch:
```bash
pinner setup --reset
```
## Non-interactive environments
The setup wizard requires an interactive terminal. For CI, scripts, or other non-interactive contexts, use individual commands instead:
```bash
# Authenticate with a JWT token
pinner auth YOUR_JWT_TOKEN
# Authenticate interactively (supports --email, --password, --otp-code flags)
pinner auth --email user@example.com
# Configure settings
pinner config set base_endpoint api.example.com
pinner config set secure true
pinner config set memory_limit 256
```
## Authentication step
When the wizard reaches authentication, you choose one of:
1. **Create a new account**: directs you to the registration page, then helps you sign in
2. **Sign in with existing account**: prompts for email and password (plus OTP if 2FA is enabled)
3. **Skip**: configure later with `pinner auth`
## Configuration step
When the wizard reaches configuration, you choose one of:
1. **Use default settings**: HTTPS enabled, default production endpoint
2. **Customize API endpoint**: enter a custom domain and choose HTTP or HTTPS
3. **Skip**: keep current or default settings
## Options
| Option | Description |
|--------|-------------|
| `--skip-auth` | Skip the authentication step |
| `--skip-config` | Skip the configuration step |
| `--reset` | Reset configuration and start fresh |
| `--non-interactive` | Not supported; errors with a message to use `pinner auth` and `pinner config` instead |
# CLI Doctor
Run `pinner doctor` when something isn't working or you want to verify your setup.
```bash
pinner doctor
```
## Example output
```
Version: 0.1.0
OS: linux/amd64
Config: ~/.config/pinner/config.yaml
Config Status: Found
Endpoint: https://pinner.xyz
Memory limit: 100 (default)
Max retries: 3
Authentication: Authenticated
Shell completion: Enabled (bash, zsh)
```
When memory limit is set to a custom value, it displays the number in megabytes:
```
Memory limit: 256 MB
```
## JSON output
For scripting or piping to other tools:
```bash
pinner doctor --json
# or
pinner doctor -j
```
Returns structured JSON with all diagnostic information.
## What gets checked
| Check | What it tells you |
|-------|-------------------|
| **Version** | CLI version and build info |
| **OS** | Operating system and architecture |
| **Config** | Where your config file lives and whether it was found |
| **Config Status** | Whether the config file exists on disk (`Found` or `Not found (will use defaults)`) |
| **Endpoint** | Which API endpoint the CLI is pointing at (derived from `base_endpoint` and `secure` settings) |
| **Memory limit** | CAR generation memory ceiling (shows `(default)` when using the built-in 100 MB default) |
| **Max retries** | Maximum retry attempts on failure |
| **Authentication** | Whether an auth token is configured |
| **Shell completion** | Which shells have tab-completion configured (checks bash, zsh, fish; pwsh on Windows) |
## When to run it
* You're reporting a bug and need to share your environment details
* The CLI can't connect to the API
* Authentication isn't working as expected
* You changed your config and want to confirm it took effect
* You're setting up the CLI on a new machine
## Next steps
If `pinner doctor` reports a problem:
* **Authentication: Not authenticated**: run `pinner auth` to authenticate
* **Config: Not found (will use defaults)**: run `pinner setup` to create one
* **Endpoint showing wrong URL**: check `pinner config get base_endpoint` and `pinner config get secure`
See [CLI Auth](/ipfs/cli/auth) for authentication details, or [CLI Config](/ipfs/cli/config) for configuration options.
# Error Reference
Every API error response includes a `reason` field for programmatic handling. This page lists every code the API can return, when it happens, what to do about it, and whether it's safe to retry.
CLI users see friendly messages instead of raw codes; the [CLI error table](#cli-error-messages) maps them.
## Error response format
```json
{
"error": {
"reason": "UNAUTHORIZED",
"details": "Access denied. Please check your credentials and try again."
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `reason` | `string` | Error code for programmatic handling |
| `details` | `string` | Human-readable description |
## Authentication (401 / 403)
| Code | HTTP | When it happens | What to do | Retry? |
|------|------|----------------|------------|--------|
| `UNAUTHORIZED` | 401 | API key is invalid, expired, or revoked | Run `pinner auth` to re-authenticate. Check `PINNER_AUTH_TOKEN` env var. | No |
| `PERMISSION_DENIED` | 403 | You don't have access to this resource | Check your plan limits. Contact support if you think this is wrong. | No |
## Upload
| Code | HTTP | When it happens | What to do | Retry? |
|------|------|----------------|------------|--------|
| `UnsupportedFormat` | 422 | File format isn't supported | Use a supported format (ZIP or CAR). | No |
| `CorruptedFile` | 500 | File is corrupted | Re-export or re-download the file and retry. | No |
| `EmptyZIP` | 500 | ZIP archive has no files | Add content to the ZIP and retry. | No |
| `PasswordProtected` | 500 | File is password-protected | Remove password protection and retry. | No |
| `FileUploadFailed` | 500 | Upload failed mid-transfer | Check network and retry with backoff. | Yes |
| `FILE_UPLOAD_API_FAILED` | 500 | Upload failed due to an internal error | Retry with backoff. If it persists, check status.pinner.xyz. | Yes |
| `FILE_PROCESSING_FAILED` | 500 | Server couldn't process the file | Check file integrity. Retry with backoff. | Yes |
| `UPLOAD_NOT_FOUND` | 404 | Upload ID doesn't exist | Check the upload ID. It may have expired or been deleted. | No |
| `BLOCK_NOT_FOUND` | 404 | Requested block doesn't exist | The content may not be fully pinned yet. Wait and retry. | Yes |
| `CID_PARSE_FAILED` | 400 | CID format is invalid | Check the CID string for typos or encoding issues. | No |
## Pin
| Code | HTTP | When it happens | What to do | Retry? |
|------|------|----------------|------------|--------|
| `PIN_FETCH_FAILED` | 500 | Couldn't retrieve pin info | Retry with backoff. If it persists, the pin may not exist. | Yes |
| `INVALID_CID` | 422 | CID format is invalid or unsupported | Verify the CID. See [CIDs](/ipfs/concepts/cids). | No |
| `INVALID_TARGET` | 422 | Target hash or peer ID is invalid | Check the target value. | No |
## Quota (429)
| Code | HTTP | When it happens | What to do | Retry? |
|------|------|----------------|------------|--------|
| `UPLOAD_QUOTA_EXCEEDED` | 429 | Too many uploads in a period | Wait and retry. Reduce upload frequency or upgrade your plan. | Yes (after cooldown) |
| `STORAGE_QUOTA_EXCEEDED` | 429 | Storage limit reached | Delete unused pins or upgrade your plan. | No |
| `DOWNLOAD_QUOTA_EXCEEDED` | 429 | Too many downloads in a period | Wait and retry. Reduce download frequency or upgrade your plan. | Yes (after cooldown) |
## Website
| Code | HTTP | When it happens | What to do | Retry? |
|------|------|----------------|------------|--------|
| `WEBSITE_NOT_FOUND` | 404 | Website doesn't exist | Check the website ID. See [Websites](/hosting/concepts/how-hosting-works). | No |
| `INVALID_WEBSITE_STATUS` | 500 | Website status value isn't valid | Check the status you're setting. | No |
| `INVALID_TARGET_TYPE` | 500 | Target type isn't valid | Must be `ipfs` or `ipns`. | No |
| `INVALID_TIMESTAMP` | 500 | Timestamp format is invalid | Use ISO 8601 / RFC 3339 format. | No |
## SSL
| Code | HTTP | When it happens | What to do | Retry? |
|------|------|----------------|------------|--------|
| `INVALID_SSL_STATUS` | 500 | SSL status value isn't valid | Check the status you're setting. | No |
| `SSL_STATUS_UPDATE_FAILED` | 500 | Couldn't update SSL status | Check domain ownership. Retry with backoff. | Yes |
## DNS
| Code | HTTP | When it happens | What to do | Retry? |
|------|------|----------------|------------|--------|
| `INVALID_DOMAIN` | 400 | Domain name is invalid | Check domain format. Must be a valid FQDN. | No |
| `INVALID_DOMAIN_FORMAT` | 400 | Domain format isn't supported | Use a standard domain format. | No |
| `INVALID_ZONE_STATUS` | 500 | DNS zone status is invalid | Check the status value. | No |
| `ZONE_NOT_FOUND` | 404 | DNS zone doesn't exist | Create the zone first. See [DNS and Domains](/hosting/concepts/dns-and-domains). | No |
| `RECORD_NOT_FOUND` | 404 | DNS record doesn't exist | Check the record ID. | No |
| `INVALID_RECORD_TYPE` | 500 | DNS record type isn't supported | Use a supported record type (A, AAAA, CNAME, TXT, MX, NS). | No |
| `DUPLICATE_RECORD` | 409 | DNS record already exists | Use the existing record or delete it first. | No |
| `DELETE_FAILED` | 500 | Couldn't delete DNS zone | Retry with backoff. | Yes |
| `UPDATE_FAILED` | 500 | Couldn't update DNS zone | Retry with backoff. | Yes |
| `VALIDATION_FAILED` | 500 | DNS validation failed | Check nameserver configuration. Retry after fixing. | Yes |
## Validation
| Code | HTTP | When it happens | What to do | Retry? |
|------|------|----------------|------------|--------|
| `INVALID_REQUEST` | 422 | Request parameter is invalid | Check the API docs for required parameters. | No |
| `INVALID_IDENTIFIER` | 422 | ID format is invalid | Check the identifier format. | No |
| `INVALID_UUID_FORMAT` | 400 | UUID format is invalid | Use standard UUID format (e.g. `550e8400-e29b-41d4-a716-446655440000`). | No |
| `METADATA_FETCH_FAILED` | 500 | Couldn't fetch metadata | Retry with backoff. | Yes |
## CLI error messages
The CLI translates API and SDK errors into friendly messages. If you see one of these, here's what it means:
| CLI message | Cause | What to do |
|-------------|-------|------------|
| `not authenticated` | No API key configured | Run `pinner auth` |
| `path is required` | No file path given | Provide a file path argument |
| `CID is required` | No CID argument given | Provide a CID argument |
| `invalid CID` | CID format is wrong | Check the CID string |
| `no CIDs provided` | Empty CID list | Provide at least one CID |
| `file not found` | File doesn't exist locally | Check the file path |
| `directory not found` | Directory doesn't exist locally | Check the directory path |
| `permission denied` | Can't read the file | Check file permissions |
| `pin not found` | Pin doesn't exist on Pinner | Check the CID; it may not be pinned |
| `pinning failed` | Pin operation failed on the server | Retry. If it persists, check status.pinner.xyz |
| `upload failed` | Upload didn't complete | Check the error details. Retry with backoff. |
| `upload interrupted` | You pressed Ctrl+C or the network dropped | The CLI supports resuming; just retry the same command |
| `network timeout` | Server didn't respond in time | Check your connection. Retry with backoff. |
| `connection failed` | Can't reach Pinner servers | Check your internet connection and firewall |
| `configuration not found` | No config file | Run `pinner setup` |
| `configuration invalid` | Config file is malformed | Run `pinner setup --reset` |
| `service unavailable` | Server is temporarily down | Retry after a few minutes. Check status.pinner.xyz |
## Retry strategy
Some errors are safe to retry. Use exponential backoff: start at 1 second, double each attempt:
* **Always retry**: 429 (quota exceeded: wait for cooldown), 500 (server error: temporary)
* **Never retry**: 400, 401, 403, 404, 409, 410, 422 (client error: you must fix the request)
```typescript
import { isRetryable } from "@lumeweb/pinner";
async function uploadWithRetry(file, maxRetries = 3) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await pinner.upload(file);
} catch (error) {
lastError = error;
if (!isRetryable(error)) {
throw error;
}
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
throw lastError;
}
```
# Websites & Domains
Pinner lets you host content on IPFS and make it accessible through a domain name. There are two paths depending on what kind of name you're using.
## Which path are you on?
### I have an onchain name (`.eth`, etc.)
Onchain names resolve through the blockchain, not DNS. No DNS configuration, no SSL certificates: just point your name at your content and set the contenthash.
→ [**Onchain Names Quickstart**](/onchain/quickstart)
### I have a DNS domain (`.com`, `.org`, etc.)
DNS domains resolve through traditional nameservers. Pinner can host your DNS, provision SSL certificates, and serve your content through ipfs.pub.
→ [**Website Hosting Quickstart**](/hosting/quickstart)
***
## What's the difference?
| | Onchain Names | Website Hosting |
|---|---|---|
| **Name type** | `.eth` and other onchain names | `.com`, `.org`, `.io`, any DNS domain |
| **Resolution** | On-chain contenthash → IPNS → IPFS | DNS → CNAME/DNSLink → ipfs.pub |
| **Setup** | `pinner point` + set contenthash | Create website + configure DNS + validate |
| **DNS hosting** | Not needed | Optional (Pinner can host your DNS) |
| **SSL** | Not needed (handled by resolver) | Automatic via Let's Encrypt |
| **Updates** | `pinner point` again (same contenthash) | Republish IPNS or update website target |
Both paths serve content from IPFS. The difference is how the domain reaches that content: through the blockchain or through DNS.
# Onchain Names Quickstart
If you have an onchain name like `.eth`, you can point it at your IPFS content with a single command. No DNS configuration, no SSL certificates, no domain validation: just point and go.
## What you need
* An onchain name (e.g., `vitalik.eth`)
* Content already pinned to IPFS (you'll need the CID)
* The [Pinner CLI installed](/ipfs/cli/getting-started) and [authenticated](/ipfs/cli/auth)
## Point your name at content
```bash
pinner point vitalik.eth --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
```
You'll see output like:
```
Domain pointed successfully
Name vitalik.eth
CID bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
IPNS Name k51qzi5uqu5djx...
Contenthash ipns://k51qzi5uqu5djx...
```
## Set your contenthash
Copy the `Contenthash` value and set it in your name manager. For ENS names, this is the `contenthash` record in your ENS controller.
Once set, your name resolves to your content.
## Verify
Visit your name through a resolver:
```
https://vitalik.eth.limo
```
Your content should load. If it doesn't, check that:
1. The contenthash is set correctly in your name manager
2. The CID matches your content
3. You're using the IPNS contenthash (starts with `ipns://`), not a raw CID
## Update your content
When you have new content, just point again with the new CID:
```bash
pinner point vitalik.eth --cid bafybeig...updated-cid
```
The same IPNS key is reused and republished. The contenthash in your name manager stays the same: it points at the IPNS name, not the CID directly.
## Remove a pointing
```bash
pinner unpoint vitalik.eth
```
This deletes the IPNS key. The name will no longer resolve to IPFS content.
:::warning
`unpoint` deletes the IPNS key. Any name pointing at this IPNS name will stop resolving immediately.
:::
## How it works
Under the hood, `point` creates an IPNS key named after your domain, publishes your CID to it, and gives you the contenthash to set in your name manager. When someone visits your name, the resolver follows the contenthash → IPNS → CID path to serve your content.
For more detail, see [How Onchain Names Work](/onchain/concepts/how-onchain-names-work) and the [point & unpoint reference](/onchain/how-to/point-unpoint).
## Next steps
* [Point & unpoint reference](/onchain/how-to/point-unpoint): all flags, JSON output, troubleshooting
* [How onchain names work](/onchain/concepts/how-onchain-names-work): IPNS, contenthash, and the resolution path
* [IPNS and mutable addresses](/hosting/concepts/ipns): deeper dive into IPNS
# Point & Unpoint
The `point` and `unpoint` commands manage the link between an onchain name and IPFS content via IPNS.
## `point`
Point an onchain name at IPFS content.
```bash
pinner point --cid
```
### Arguments
| Argument | Description |
|----------|-------------|
| `` | The onchain name to point (e.g., `vitalik.eth`) |
### Flags
| Flag | Alias | Description |
|------|-------|-------------|
| `--cid` | `-c` | CID to point the name at (required) |
### Behavior
`point` is idempotent:
1. If no IPNS key exists for the name, one is created automatically
2. If an IPNS key already exists, it's reused and the new CID is published to it
3. The contenthash (`ipns://`) is returned for you to set in your name manager
### Examples
Point a name at content:
```bash
pinner point vitalik.eth --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
```
Update content (same command, new CID):
```bash
pinner point vitalik.eth --cid bafybeig...updated-cid
```
Get JSON output:
```bash
pinner point vitalik.eth --cid bafybeig... --json
```
```json
{
"name": "vitalik.eth",
"cid": "bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e",
"ipns_name": "k51qzi5uqu5djx...",
"contenthash": "ipns://k51qzi5uqu5djx...",
"created": true
}
```
The `created` field is `true` when a new IPNS key was created, `false` when an existing key was reused.
### What to do after running `point`
`point` handles Pinner's side: the IPNS key is created and the CID is published. But your name won't resolve until you set the contenthash in your name manager.
1. Copy the `Contenthash` value from the output
2. Set it as the `contenthash` record for your name (in your ENS controller, etc.)
3. Verify by visiting your name through a resolver (e.g., `https://vitalik.eth.limo` for ENS names)
### Troubleshooting
| Problem | Cause | Fix |
|---------|-------|-----|
| `name is required` | No name argument provided | Pass the name: `pinner point vitalik.eth --cid ` |
| `failed to publish to IPNS` | CID is invalid or network error | Verify the CID is valid and try again |
| Name doesn't resolve after setting contenthash | Contenthash not set, or set incorrectly | Verify the contenthash matches the output from `point` exactly; it should start with `ipns://` |
| Name resolves but shows old content | IPNS record hasn't propagated | Wait a moment for IPNS propagation and refresh |
***
## `unpoint`
Remove the IPNS key for an onchain name.
```bash
pinner unpoint
```
### Arguments
| Argument | Description |
|----------|-------------|
| `` | The onchain name to unpoint (e.g., `vitalik.eth`) |
### Behavior
1. Finds the IPNS key for the given name
2. Deletes the key
3. The name will no longer resolve to IPFS content
:::warning
`unpoint` deletes the IPNS key immediately. Any name pointing at this IPNS name will stop resolving. This cannot be undone; you'll need to run `point` again to create a new key, and then update your contenthash to the new IPNS name.
:::
### Examples
Remove a pointing:
```bash
pinner unpoint vitalik.eth
```
You'll see output like:
```
Domain unpointed successfully
Name vitalik.eth
IPNS Name k51qzi5uqu5djx...
```
JSON output:
```bash
pinner unpoint vitalik.eth --json
```
```json
{
"name": "vitalik.eth",
"ipns_name": "k51qzi5uqu5djx...",
"deleted": true
}
```
### Troubleshooting
| Problem | Cause | Fix |
|---------|-------|-----|
| `no IPNS key found for "vitalik.eth"` | No IPNS key with that name exists | The name may have already been unpointed, or was never pointed |
| Name still resolves after unpoint | Contenthash still set in name manager | Remove the contenthash from your name manager separately; `unpoint` only handles Pinner's side |
***
## When to use `point` vs lower-level IPNS commands
`point` is the recommended way to manage onchain names. It handles key creation, publishing, and idempotent updates in a single command.
Use the lower-level `ipns` commands when you need more control:
| What you need | Command |
|---------------|---------|
| Quick onchain name setup | `pinner point --cid ` |
| Import an existing private key | `pinner ipns keys create --key ` |
| Set a custom TTL on the IPNS record | `pinner ipns publish --key-name --ttl 24h` |
| Resolve an IPNS name | `pinner ipns resolve ` |
| Republish without changing the CID | `pinner ipns republish ` |
| List all IPNS keys | `pinner ipns keys list` |
| Delete a key by name or ID | `pinner ipns keys delete ` |
See [Publish with IPNS](/hosting/how-to/publish-ipns) for the full IPNS reference.
# How Onchain Names Work
Onchain name systems like ENS (Ethereum Name Service) let you map a human-readable name to IPFS content. This page explains how that resolution works and where Pinner fits in.
## The resolution path
When someone visits `vitalik.eth` in a browser, here's what happens:
1. **The browser queries the name resolver.** For ENS names, services like eth.limo handle this.
2. **The resolver looks up the `contenthash` record.** This is an onchain record stored in the naming contract. It points at either an IPFS CID or an IPNS name.
3. **The resolver follows the contenthash.** If it's an IPNS name, the resolver looks up the current CID that the IPNS name points to. If it's a direct CID, it fetches that.
4. **The content is served from IPFS.** The resolver's gateway fetches the CID from the IPFS network and serves it to the browser.
```
vitalik.eth
→ contenthash: ipns://k51qzi5uqu5djx...
→ IPNS resolves to: bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
→ Content served from IPFS
```
## Why IPNS, not direct CIDs
You could set your contenthash to a direct CID (`ipfs://bafybeig...`) instead of an IPNS name. Some people do. But there's a trade-off:
| Approach | Update process | Trade-off |
|----------|---------------|-----------|
| Direct CID | Change the contenthash on-chain every time | Requires an on-chain transaction for each update (costs gas, takes time) |
| IPNS name | Update the IPNS record off-chain; contenthash stays the same | One on-chain transaction to set the contenthash, then all updates happen on Pinner's side |
IPNS adds a layer of indirection. Your contenthash points at a stable IPNS name, and you update what that IPNS name resolves to without touching the blockchain again. When you run `pinner point`, you're updating the IPNS record; no on-chain transaction needed.
## Where Pinner fits
Pinner's job in this flow is straightforward:
1. **Pin your content**: make sure it stays available on IPFS
2. **Manage the IPNS key**: create it, publish CIDs to it
3. **Keep the IPNS record current**: republish when you update content
`pinner point` does steps 2 and 3 in one command. Step 1 happens separately when you upload or pin content.
```
Your content → Pinner pins it → Pinner publishes to IPNS → You set contenthash → Resolver serves it
```
Pinner doesn't handle the on-chain transaction (setting the contenthash). That's done in your name manager: your ENS controller or smart contract.
## What about DNS?
Onchain name resolution doesn't use DNS. There are no CNAME records, nameserver delegation, or DNS hosting involved. The contenthash is stored on-chain, and resolvers read it directly from the blockchain.
This is different from [website hosting with a DNS domain](/hosting/concepts/dns-and-domains), where Pinner sets up DNS records and SSL certificates. Onchain names skip all of that; the blockchain is the name resolution layer.
## Key terminology
| Term | What it means |
|------|---------------|
| **Contenthash** | The on-chain record that maps a name to content. Set in your name manager. |
| **IPNS** | InterPlanetary Name System: a mutable address that always points at the current CID |
| **IPNS key** | A cryptographic key used to publish and update an IPNS record. Pinner manages these for you. |
| **Resolver** | A service that looks up on-chain records and serves the content (e.g., eth.limo for ENS names) |
## Related
* [Onchain Names Quickstart](/onchain/quickstart): get running in under a minute
* [Point & unpoint reference](/onchain/how-to/point-unpoint): command details and troubleshooting
* [IPNS and mutable addresses](/hosting/concepts/ipns): deeper explanation of how IPNS works
* [How website hosting works](/hosting/concepts/how-hosting-works): the DNS-based alternative for .com domains
# Quickstart
Deploy a static site to IPFS and make it accessible via a custom domain, all in under 5 minutes.
You need a Pinner account and an API key. Don't have one? [Create an API key](/account/api-tokens).
:::code-group
```bash [CLI]
# 1. Authenticate (interactive: prompts for email and password)
pinner auth
# 2. Build your site (example with Vite)
npm run build
# 3. Deploy: create a website pointing to your CID
pinner websites create mysite.pinner.xyz --cid QmYourCIDHere
# Done: your site is live on IPFS
```
```typescript [SDK]
import { Pinner } from "@lumeweb/pinner";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
// Create a website pointing to your Content Identifier (CID)
const site = await pinner.websites.createWebsite({
domain: "mysite.pinner.xyz",
target_type: "ipfs",
target_hash: "QmYourCIDHere",
});
console.log("Site ID:", site.id);
console.log("Domain:", site.domain);
console.log("Status:", site.status);
```
:::
### Non-interactive (CI/CD)
If you already have an API key from another machine or for automation:
```bash
pinner auth YOUR_JWT
```
## What just happened
The CLI created a website record that maps a domain to an IPFS CID. You provide the domain and CID, and Pinner handles routing and DNS validation. The SDK does the same thing; you give it a `domain`, `target_type`, and `target_hash` (CID), and Pinner handles the rest.
## Next steps
* [Deploy a static site end-to-end](/hosting/tutorials/deploy-static-site): a full walkthrough with custom domains
* [Add a custom domain](/hosting/how-to/custom-domains)
* [Understand how website hosting works](/hosting/concepts/how-hosting-works)
# Deploy a Static Site
This tutorial walks you through the full process: build a static site, deploy it to IPFS, and make it accessible at a custom domain. By the end, you'll understand each step of the deployment pipeline.
## Prerequisites
* A Pinner account with an [API key](/account/api-tokens)
* The [Pinner CLI installed and authenticated](/ipfs/cli/auth)
* A static site to deploy (we'll use Vite as an example)
* A domain you control (for custom domain setup)
## Step 1: Build your site
Build your project the way you normally would. For a Vite project:
```bash
npm run build
```
This outputs static files to `./dist`. Any static site generator works: Next.js export, Astro, Hugo, plain HTML. As long as the output is a directory of static files, Pinner can host it.
## Step 2: Upload with the CLI
```bash
pinner upload ./dist
```
This uploads the directory to IPFS and prints the CID. Note the CID from the output; you'll use it in the next step.
## Step 3: Create the website
Create a website record linking your domain to the uploaded content:
```bash
pinner websites create mysite.example.com --cid
```
:::info
The CLI provides an interactive wizard that guides you through the full process:
```bash
pinner websites wizard
```
The wizard handles authentication, content source, target type, domain, DNS mode, and validation in one guided flow.
:::
## Step 4: (SDK) Upload and create the website
If you're using the SDK, use `uploadDirectory` for directory uploads and handle the website creation separately:
```typescript
import { Pinner } from "@lumeweb/pinner";
import fs from "fs";
import path from "path";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN! });
// Upload the build output as a directory
const files: File[] = [];
for (const entry of fs.readdirSync("./dist", { recursive: true })) {
const fullPath = path.join("./dist", entry.toString());
if (!fs.statSync(fullPath).isFile()) continue;
const buffer = fs.readFileSync(fullPath);
files.push(new File([buffer], entry.toString()));
}
const operation = await pinner.uploadDirectory(files);
const result = await operation.result;
console.log("CID:", result.cid);
// Create the website
const site = await pinner.websites.createWebsite({
domain: "mysite.example.com",
target_type: "ipfs",
target_hash: result.cid,
});
console.log("Website created:", site.id, site.domain);
```
## Step 5: Set up your custom domain
If you used a custom domain (not a `*.pinner.xyz` subdomain), you need to prove you own it.
The website response includes a `validation_token` field. Add a DNS TXT record at your domain registrar:
| Record | Name | Value |
|--------|------|-------|
| TXT | (shown in website details) | (the `validation_token` value, e.g. `pinner-verify=`) |
The exact name for the TXT record is shown in your website details. You also need a DNSLink TXT record pointing to your content:
| Record | Name | Value |
|--------|------|-------|
| TXT | `_dnslink.` | `dnslink=/ipfs/` |
Then trigger validation:
```typescript
const validation = await pinner.websites.validateWebsite(site.id);
console.log("Valid:", validation.valid, validation.message);
```
Or with the CLI:
```bash
pinner websites validate mysite.example.com
```
## Step 6: Add a CNAME record
Point your domain to Pinner's hosting infrastructure. The gateway domain is shown in your website details or configuration:
```bash
pinner websites config
```
Use the gateway domain shown for your CNAME record:
| Record | Name | Value |
|--------|------|-------|
| CNAME | `www` | (the gateway domain from config) |
For root domains, delegate to Pinner's nameservers using the `--dns-hosting` flag when creating the website, or use an A record if your DNS provider supports it.
## Step 7: Wait for SSL
Pinner provisions an SSL certificate automatically after your DNS records are in place. No configuration on your part; just wait.
Check the status with the CLI:
```bash
pinner websites ssl status mysite.example.com
```
Or with the SDK:
```typescript
const ssl = await pinner.websites.getSSLStatus("mysite.example.com");
console.log("SSL status:", ssl.status);
```
SSL moves through these states: `pending` → `issuing` → `ready`. If something goes wrong, it lands in `failed`.
You can also watch for SSL readiness:
```typescript
const watcher = pinner.websites.watchSSL("mysite.example.com", {
interval: 5000, // check every 5 seconds
timeout: 300000, // give up after 5 minutes
});
await watcher.start({
onReady: (status) => console.log("SSL ready!", status),
onError: (error) => console.error("SSL error:", error.message),
onStatus: (status) => console.log("SSL progress:", status.status),
});
```
## Step 8: Visit your site
Once DNS propagates and SSL is ready, your site is live at `https://mysite.example.com`.
## Recap
You built a static site, uploaded it to IPFS, created a website record, proved domain ownership with DNS validation records, pointed DNS at Pinner, and let SSL provision automatically. That's the full cycle.
## Next steps
* [Update your site with a new deploy](/hosting/how-to/manage-websites)
* [Use IPNS for updatable content addresses](/hosting/concepts/ipns)
* [Automate deploys with GitHub Actions](/hosting/how-to/deploy-github-actions)
# Deploy with GitHub Actions
Deploy your static site to IPFS via Pinner automatically every time you push to your repository, using the [`lumeweb/pinner-deploy-action`](https://github.com/lumeweb/pinner-deploy-action) GitHub Action.
## Set up secrets
Add your Pinner API key as a repository secret:
1. Go to **Settings → Secrets and variables → Actions**
2. Click **New repository secret**
3. Name: `PINNER_AUTH_TOKEN`
4. Value: your API key from [API Keys](/account/api-tokens)
## Create the workflow
Create `.github/workflows/deploy.yml`:
```yaml
name: Deploy to Pinner
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: npm ci && npm run build
- name: Deploy to Pinner
uses: lumeweb/pinner-deploy-action@v0
with:
api-key: ${{ secrets.PINNER_AUTH_TOKEN }}
path: ./dist
```
This workflow builds your site and deploys the `./dist` directory to IPFS via Pinner in a single step. The action outputs the content CID and a gateway URL, which you can use in subsequent steps.
## Upload and set up a domain
To automatically point a domain at the deployed content, add the `domain` input. The action will create or update the website for you:
```yaml
- name: Deploy to Pinner
uses: lumeweb/pinner-deploy-action@v0
with:
api-key: ${{ secrets.PINNER_AUTH_TOKEN }}
path: ./dist
domain: myapp.example.com
```
When `domain` is set, the action looks up an existing website for that domain and updates its `target_hash`, or creates a new website if none exists.
## Deploy with IPNS for continuous updates
If your site updates frequently, use IPNS so the domain always resolves to the latest content. Pass an `ipns-key` (a key name or numeric ID) to publish the CID under that key:
```yaml
- name: Deploy to Pinner
uses: lumeweb/pinner-deploy-action@v0
with:
api-key: ${{ secrets.PINNER_AUTH_TOKEN }}
path: ./dist
ipns-key: my-app
domain: myapp.example.com
```
The action resolves the key name to its numeric ID, publishes the new CID to IPNS, and updates the website's domain, all in one step.
## Clean up previous pins
Set `remove-previous: true` to automatically unpin the old CID after a successful deploy. This requires either `ipns-key` or `domain` so the action can identify the previous CID:
```yaml
- name: Deploy to Pinner
uses: lumeweb/pinner-deploy-action@v0
with:
api-key: ${{ secrets.PINNER_AUTH_TOKEN }}
path: ./dist
ipns-key: my-app
domain: myapp.example.com
remove-previous: true
```
## Pin an existing CID
If you already have a CID (for example, from a previous build step), you can skip the upload and just pin it:
```yaml
- name: Pin existing CID
uses: lumeweb/pinner-deploy-action@v0
with:
api-key: ${{ secrets.PINNER_AUTH_TOKEN }}
cid: QmExampleCID...
```
Either `path` or `cid` must be provided.
## Action inputs
| Input | Required | Default | Description |
| ----------------- | -------- | ------------------------- | ----------------------------------------------------------------- |
| `api-key` | Yes | N/A | Pinner API key |
| `path` | No | N/A | Local directory or file to upload to IPFS |
| `cid` | No | N/A | Existing CID to pin (skip upload) |
| `endpoint` | No | `https://ipfs.pinner.xyz` | Pinner API endpoint |
| `ipns-key` | No | N/A | IPNS key name or ID to publish under |
| `domain` | No | N/A | Domain for gateway website setup |
| `remove-previous` | No | `false` | Remove previous pin after deploy. Requires `ipns-key` or `domain` |
Either `path` or `cid` must be provided.
## Action outputs
| Output | Description |
| ------------- | ----------------------------------- |
| `cid` | CID of the uploaded/pinned content |
| `gateway-url` | Gateway URL for the content |
| `ipns-name` | IPNS name if `ipns-key` was set |
| `website-id` | Website ID if `domain` was set |
## Deploy with the SDK
If you prefer programmatic control, you can call the [`@lumeweb/pinner`](https://www.npmjs.com/package/@lumeweb/pinner) SDK directly from a custom script:
```yaml
- name: Deploy with SDK
env:
PINNER_AUTH_TOKEN: ${{ secrets.PINNER_AUTH_TOKEN }}
run: node scripts/deploy.mjs
```
```javascript
// scripts/deploy.mjs
import { Pinner } from "@lumeweb/pinner";
import fs from "fs";
import path from "path";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
// Upload a directory
const files: File[] = [];
for (const entry of fs.readdirSync("./dist", { recursive: true })) {
const fullPath = path.join("./dist", entry.toString());
if (!fs.statSync(fullPath).isFile()) continue;
files.push(new File([fs.readFileSync(fullPath)], entry.toString()));
}
const operation = await pinner.uploadDirectory(files, { name: "my-site" });
const result = await operation.result;
console.log("CID:", result.cid);
// Update existing website or create a new one
const siteId = Number(process.env.WEBSITE_ID);
if (siteId) {
await pinner.websites.updateWebsite(siteId, {
domain: "mysite.example.com",
target_hash: result.cid,
target_type: "ipfs",
});
console.log("Updated website", siteId);
} else {
const site = await pinner.websites.createWebsite({
domain: "mysite.example.com",
target_hash: result.cid,
target_type: "ipfs",
});
console.log("Created website", site.id);
}
```
### Publish to IPNS with the SDK
```javascript
// scripts/deploy-ipns.mjs
import { Pinner } from "@lumeweb/pinner";
import fs from "fs";
import path from "path";
const pinner = new Pinner({ jwt: process.env.PINNER_AUTH_TOKEN });
// Upload a directory
const files: File[] = [];
for (const entry of fs.readdirSync("./dist", { recursive: true })) {
const fullPath = path.join("./dist", entry.toString());
if (!fs.statSync(fullPath).isFile()) continue;
files.push(new File([fs.readFileSync(fullPath)], entry.toString()));
}
const operation = await pinner.uploadDirectory(files, { name: "my-site" });
const result = await operation.result;
// Publish to IPNS
const IPNS_KEY_ID = Number(process.env.IPNS_KEY_ID);
await pinner.ipns.publish({
cid: result.cid,
key_id: IPNS_KEY_ID,
});
console.log("Published to IPNS:", result.cid);
```
The domain resolves to the IPNS name, which always points at the latest CID.
## Next steps
* [Understand IPNS publishing](/hosting/concepts/ipns)
* [Set up a custom domain](/hosting/how-to/custom-domains)
* [Monitor SSL provisioning](/hosting/how-to/check-ssl)
# Manage Websites
List your websites, update their content, and delete sites you no longer need.
## List websites
:::code-group
```bash [CLI]
pinner websites list
```
```typescript [SDK]
const result = await pinner.websites.listWebsites();
console.log(`You have ${result.total} website(s)`);
for (const site of result.data) {
console.log(`- ${site.domain} (${site.status}) → ${site.target_hash}`);
}
```
:::
## Get a single website
```typescript
const site = await pinner.websites.getWebsite(42);
console.log(site.domain, site.status, site.target_hash);
```
## Update a website
Point your domain at new content after a rebuild:
:::code-group
```bash [CLI]
# Deploy new content and update the website record
pinner websites update --cid
```
```typescript [SDK]
// Upload new content first (for a directory of files)
const operation = await pinner.uploadDirectory(newFiles);
const result = await operation.result;
// Update the website to point at the new CID
const updated = await pinner.websites.updateWebsite(42, {
domain: "example.com",
target_type: "ipfs",
target_hash: result.cid,
});
console.log("Updated:", updated.domain, "→", updated.target_hash);
```
:::
The `updateWebsite` method accepts a `WebsiteRequest` object with `domain`, `target_type`, and `target_hash`. All fields are sent in the request; omit a field to leave it unchanged, or set it to update. To enable DNS hosting, include `dns_hosting_enabled`:
```typescript
const updated = await pinner.websites.updateWebsite(42, {
domain: "newdomain.example.com",
target_type: "ipfs",
target_hash: "QmNewCID",
dns_hosting_enabled: true,
});
```
:::tip
When changing `target_hash`, you must also provide `target_type` (`"ipfs"` or `"ipns"`). Omitting `target_type` when `target_hash` is present will return a validation error.
:::
## Delete a website
:::code-group
```bash [CLI]
pinner websites delete
```
```typescript [SDK]
await pinner.websites.deleteWebsite(42);
```
:::
:::warning
Deleting a website performs a soft delete: the record is marked as deleted but preserved. DNS records for the domain are cleaned up automatically. Your content stays on IPFS unless you also unpin it, but the domain mapping and SSL certificate are removed. Blocked websites cannot be deleted.
:::
## Validate domain ownership
If your custom domain hasn't been validated yet, trigger a validation check:
```typescript
const result = await pinner.websites.validateWebsite(42);
console.log(result.valid, result.reason, result.message);
```
Validation reasons:
| Reason | Meaning |
|--------|---------|
| `validated` | Domain ownership confirmed |
| `token_expired` | Validation token has expired, redeploy |
| `dns_missing` | The TXT record wasn't found |
| `dns_mismatch` | TXT record exists but token doesn't match |
| `token_missing` | No validation token on the website record |
## Website properties
Every website has these fields:
| Field | Description |
|-------|-------------|
| `id` | Unique identifier |
| `domain` | The domain name for the website |
| `target_type` | Content address type (`ipfs` or `ipns`) |
| `target_hash` | The CID or IPNS name the domain points to |
| `status` | Current website status (see below) |
| `active_cid` | The currently-published content CID (present for IPNS targets) |
| `validation_token` | Token for proving domain ownership |
| `validation_record_host` | DNS hostname for the TXT record (e.g. `pinner-verify.example.com`) |
| `validation_expires_at` | When the validation token expires |
| `dns_hosting_enabled` | Whether Pinner manages DNS for this domain |
| `dns_zone_id` | ID of the DNS zone hosting this website's records (when DNS hosting enabled) |
| `ipns_key_id` | ID of the linked IPNS key (set when DNS hosting auto-creates a key) |
| `is_subdomain` | Whether this is a subdomain of a shared DNS zone |
| `gateway_domain` | Gateway domain for constructing public URLs |
| `last_checked_at` | When the website was last checked by the janitor |
| `ssl` | SSL certificate status (see [SSL](/hosting/concepts/ssl)) |
| `expired` | Whether the validation token has expired |
| `created` / `updated` | Timestamps |
### Website statuses
A website can be in one of four statuses:
| Status | Description |
|--------|-------------|
| `pending_validation` | Awaiting domain ownership validation (initial status after creation) |
| `active` | Domain validated and website is live |
| `broken` | Target content is unreachable |
| `blocked` | Website has been blocked by an administrator |
# Publish with IPNS
:::tip
Using an onchain name like `.eth`? The `pinner point` command handles IPNS key creation, publishing, and updates in one step. See [Onchain Names Quickstart](/onchain/quickstart).
:::
IPNS (InterPlanetary Name System) lets you publish content under a stable name that can be updated to point at new CIDs. This is useful for websites that update frequently; instead of changing DNS records every time, you update the IPNS record.
## How IPNS fits into website hosting
When you create a website, you can set `target_type` to either `ipfs` or `ipns`:
* **`ipfs`**: The domain points directly at a CID. To update, change `target_hash` on the website record.
* **`ipns`**: The domain points at an IPNS name. To update, republish the IPNS name with a new CID. The domain doesn't change.
IPNS adds a layer of indirection. You update content by republishing the IPNS name instead of updating the website record.
## Manage IPNS keys
### List keys
:::code-group
```bash [CLI]
pinner ipns keys list
```
```typescript [SDK]
const keys = await pinner.ipns.listKeys();
console.log(`You have ${keys.length} key(s)`);
for (const key of keys) {
console.log(`- ${key.id}: ${key.ipns_name}`);
}
```
:::
### Create a key
:::code-group
```bash [CLI]
pinner ipns keys create my-website-key
```
```typescript [SDK]
const key = await pinner.ipns.createKey({
name: "my-website-key",
});
console.log("IPNS name:", key.ipns_name);
```
:::
### Delete a key
:::code-group
```bash [CLI]
pinner ipns keys delete
```
```typescript [SDK]
await pinner.ipns.deleteKey(keyId);
```
:::
:::warning
Deleting an IPNS key means the IPNS name can no longer be resolved. Any domains pointing at this name will break.
:::
## Publish content to an IPNS name
Point an IPNS name at a CID:
:::code-group
```bash [CLI]
pinner ipns publish --key-name
```
```typescript [SDK]
const result = await pinner.ipns.publish({
key_id: key.id,
cid: "QmYourCID",
});
console.log("Published:", result.name, "→", result.value);
```
:::
## Create a website with an IPNS target
```typescript
const site = await pinner.websites.createWebsite({
domain: "mysite.example.com",
target_type: "ipns",
target_hash: key.ipns_name,
});
```
Now when you update your site, publish a new CID to the same IPNS name; the domain follows automatically.
## Republish
IPNS records have a limited lifetime. Republish to extend it:
:::code-group
```bash [CLI]
pinner ipns republish
```
```typescript [SDK]
await pinner.ipns.republish(key.ipns_name);
```
:::
## Resolve an IPNS name
Look up what CID an IPNS name currently points to:
:::code-group
```bash [CLI]
pinner ipns resolve k51qzi5...
```
```typescript [SDK]
const resolved = await pinner.ipns.resolve("k51qzi5...");
console.log("Points to:", resolved.value);
```
:::
## When to use IPNS vs direct IPFS
| Scenario | Use |
|----------|-----|
| Static content that rarely changes | Direct IPFS (`target_type: "ipfs"`) |
| Frequently updated sites | IPNS (`target_type: "ipns"`) |
|| CI/CD automated deploys | IPNS: update once, domain follows |
|| One-off deployments | Direct IPFS: simpler |
IPNS resolution is slightly slower than direct IPFS because it adds a lookup step. For most sites, the difference is negligible.
# Custom Domains
By default, your site is available at a `*.pinner.xyz` subdomain. To use your own domain, you need to prove ownership and configure DNS.
## Subdomain vs custom domain
| Type | Example | Setup |
|------|---------|-------|
| Subdomain | `mysite.pinner.xyz` | Automatic; no DNS needed |
| Custom domain | `mysite.example.com` | Requires DNS validation and records |
## Step 1: Create the website with your domain
:::code-group
```typescript [SDK]
const site = await pinner.websites.createWebsite({
domain: "mysite.example.com",
target_type: "ipfs",
target_hash: "QmYourCID",
});
```
```bash [CLI]
pinner website deploy ./dist --domain mysite.example.com
```
:::
Pinner assigns a `validation_token` to the website. You'll need this in the next step.
:::info
`dns_hosting_enabled` defaults to `true` for new websites. When enabled, Pinner automatically creates a DNS zone and the required DNS records for you. If you prefer to manage DNS yourself at your registrar, set `dns_hosting_enabled: false`.
:::
## Step 2: Add DNS records
At your domain registrar, create the following DNS records:
| Record | Name | Value |
|--------|------|-------|
| TXT | `_dnslink` | `dnslink=/ipfs/QmYourCID` |
| TXT | `` | `` |
**DNSLink record**: The `_dnslink` TXT record tells IPFS gateways how to resolve your domain. Set the value to a `dnslink=` path matching your target type and hash:
* IPFS target: `dnslink=/ipfs/QmYourCID`
* IPNS target: `dnslink=/ipns/`
**Validation record**: The `validation_record_host` field on the website tells you the exact hostname for the verification TXT record (e.g. `pinner-verify.mysite.example.com`). The `validation_token` field contains the full TXT record value (e.g. `pinner-verify=abc123def456`).
:::info
The exact verification TXT record hostname and value depend on the server configuration. Always use the `validation_record_host` and `validation_token` from the website response; don't hardcode the prefix.
:::
## Step 3: Route traffic to the gateway
Add a DNS record to point your domain at the Pinner gateway:
| Record | Name | Value |
|--------|------|-------|
| ALIAS | `@` (root) | `` |
For root domains (`example.com`), use an ALIAS record pointing to the gateway domain. For subdomains, a CNAME record also works.
Use `getWebsiteConfig()` to discover the gateway domain:
```typescript
const config = await pinner.websites.getWebsiteConfig();
console.log("Gateway domain:", config.gateway_domain);
console.log("Nameservers:", config.nameservers);
```
## Step 4: Trigger validation
:::code-group
```typescript [SDK]
const result = await pinner.websites.validateWebsite(site.id);
if (result.valid) {
console.log("Domain validated!");
} else {
console.log("Not yet valid:", result.reason, result.message);
}
```
```bash [CLI]
pinner websites validate
```
:::
DNS propagation can take a few minutes. If validation fails with `dns_missing` or `dns_mismatch`, wait a few minutes and retry.
Validation checks both the DNSLink record and the verification token record. A `dns_mismatch` reason means the DNSLink record was found but doesn't match the expected target. A `token_missing` reason means the verification TXT record was not found.
## Step 5: Wait for SSL
SSL provisions automatically after DNS is in place. See [SSL](/hosting/concepts/ssl) for details on checking status.
## Use Pinner-managed DNS
If you don't want to manage DNS at your registrar, enable Pinner's DNS hosting. This delegates DNS management to Pinner entirely. DNS hosting is enabled by default for new websites.
### Disable during website creation
```typescript
const site = await pinner.websites.createWebsite({
domain: "mysite.example.com",
target_type: "ipfs",
target_hash: "QmYourCID",
dns_hosting_enabled: false,
});
```
### Update on an existing website
```typescript
await pinner.websites.updateWebsite(site.id, {
dns_hosting_enabled: false,
});
```
When DNS hosting is enabled, Pinner creates a DNS zone for your domain and handles the DNSLink, validation, and ALIAS records for you. Update your domain registrar's nameservers to point to Pinner's nameservers (shown in `getWebsiteConfig()`).
See [Manage DNS Zones](/dns/how-to/manage-zones) and [Manage DNS Records](/dns/how-to/manage-records) for the DNS-specific commands.
## Troubleshooting
### Validation keeps failing with `dns_missing`
DNS propagation can take up to 48 hours (usually a few minutes). Verify the DNSLink and verification TXT records exist:
```bash
dig TXT _dnslink.mysite.example.com
dig TXT
```
### Validation fails with `dns_mismatch`
The DNSLink TXT record was found but doesn't match the expected target. Check that the `_dnslink` TXT record value is correct; it should be `dnslink=/ipfs/` or `dnslink=/ipns/`.
### Validation fails with `token_missing`
The verification TXT record was not found at the expected hostname. Check that you added the TXT record at `` with the value from `validation_token`.
### Validation fails with `token_expired`
The validation token has expired. Re-create the website or update it to get a fresh token.
# Check SSL Status
Pinner provisions SSL certificates automatically for custom domains. Once DNS is configured and validated, Pinner's certificate manager (backed by Caddy) handles provisioning; you just wait for the process to complete.
## Check the current status
```typescript
const response = await pinner.websites.getSSLStatus("mysite.example.com");
const ssl = response.ssl;
if (ssl) {
console.log("Status:", ssl.status);
if (ssl.error) {
console.log("Error:", ssl.error);
}
if (ssl.issued_at) {
console.log("Issued at:", ssl.issued_at);
}
}
```
## SSL states
| Status | Meaning |
|--------|---------|
| `pending` | Waiting to start provisioning: DNS may not have propagated yet |
| `issuing` | Certificate is being requested and provisioned |
| `ready` | Certificate issued and active; the site is serving HTTPS |
| `failed` | Provisioning failed; check the `error` field for details |
## Using the CLI
You can also check SSL status from the command line:
```bash
# Check once
pinner websites ssl status example.com
# Watch for changes (polls until settled)
pinner websites ssl status example.com --watch
# JSON output
pinner websites ssl status example.com --json
```
## Troubleshooting
### SSL stuck at `pending`
DNS records haven't propagated yet. This is normal; it can take a few minutes to a few hours depending on your registrar. Verify your CNAME and TXT records are in place:
```bash
dig CNAME mysite.example.com
dig TXT pinner-verify.mysite.example.com
```
### SSL in `failed` state
Check the `error` field for details. Common causes:
* **DNS not pointing at Pinner**: The CNAME or A record is missing or pointing elsewhere
* **Domain validation expired**: Re-validate the domain (see [Custom Domains](/hosting/how-to/custom-domains))
* **Rate limit hit**: Too many SSL requests in a short time; wait and retry
### Keep watching for SSL readiness
Use the CLI's `--watch` flag to poll until the certificate reaches `ready` or `failed`:
```bash
pinner websites ssl status example.com --watch
```
# How Website Hosting Works
Pinner hosts static websites on IPFS and makes them accessible through regular domain names with HTTPS. No servers to manage, no containers to configure; just upload your files and point a domain at them.
## The pieces
Website hosting on Pinner involves three systems working together:
1. **IPFS storage**: Your site's files are pinned on IPFS and addressed by Content Identifier (CID)
2. **DNS**: Uses DNSLink TXT records to map your domain to IPFS content
3. **SSL**: Provides HTTPS for your domain automatically via Caddy on-demand TLS
## The flow
```
Build output → Upload to IPFS → Get CID → Create website record → Configure DNSLink → Validate DNS → SSL provisions → Site is live
```
Here's what happens at each step:
### Upload
Your static files (HTML, CSS, JS, images) get uploaded to IPFS as a directory. The directory gets a Content Identifier (CID), a content-addressed identifier that represents your entire site.
### Website record
A website record on Pinner maps a domain to IPFS content. It stores:
* The **domain** name (e.g. `mysite.example.com`)
* The **target type**: either `ipfs` (direct IPFS CID) or `ipns` (IPNS name for updatable content)
* The **target hash**: the CID or peer ID identifying the content
* The **validation token** used to prove you own the domain
* The **SSL status** tracking certificate provisioning (`pending` → `issuing` → `ready` or `failed`)
### DNSLink
Pinner uses **DNSLink** to map your domain to IPFS content. DNSLink is an IPFS convention that uses a `TXT` record at `_dnslink.` to point to content on the IPFS network.
When Pinner manages your DNS, it creates these records:
| Record | Type | Purpose |
|--------|------|---------|
| `_dnslink.mysite.example.com` | `TXT` | DNSLink target (e.g. `dnslink=/ipfs/Qm...` or `dnslink=/ipns/12D3KooW...`) |
| `pinner-verify.mysite.example.com` | `TXT` | Validation token, proves domain ownership (e.g. `pinner-verify=a1b2c3...`) |
| `mysite.example.com` | `ALIAS` | Points to the gateway domain so traffic reaches Pinner |
For self-managed DNS, you need to add the `_dnslink` TXT record yourself so the gateway can resolve your content. The DNSLink value follows the format `dnslink=/ipfs/` for direct IPFS targets or `dnslink=/ipns/` for IPNS targets.
### DNS hosting
For Pinner to serve your domain, DNS must point at Pinner's infrastructure. You can either:
* **Use Pinner-managed DNS**: Delegate the DNS zone to Pinner's nameservers (powered by PowerDNS). Pinner automatically creates and manages the DNSLink, validation, and ALIAS records for you. This also enables automatic updates; when you change your site's content, Pinner updates the DNSLink record to point at the new CID.
* **Bring your own DNS**: Add the required `_dnslink` TXT record and other DNS entries at your registrar. You're responsible for updating them when content changes.
When you use Pinner-managed DNS with a direct IPFS target, Pinner automatically creates an IPNS key and converts the target to IPNS. This lets Pinner publish new CIDs under the same IPNS name, and the DNSLink record always resolves to the latest content; no manual DNS updates needed.
### SSL
Once DNS is in place, Pinner automatically provisions an SSL certificate for your domain via Caddy's on-demand TLS. Before issuing a certificate, Caddy checks with Pinner's gateway to confirm the domain is allowed. You don't need to buy, install, or renew certificates; Pinner handles the entire lifecycle.
### How the gateway serves your site
When a visitor hits `https://mysite.example.com`, the request flows through Pinner's gateway:
1. **Access check**: The gateway extracts the domain from the `Host` header and checks with Pinner's API to verify the website is active and allowed
2. **Path rewrite**: The request path is rewritten to `/ipfs/` or `/ipns/` based on the target type
3. **Content resolution**: The gateway uses DNSLink resolution and the Boxo IPFS library to fetch your content from the IPFS network
4. **Serve**: The content is served to the browser with proper headers (ETag, Cache-Control, range request support)
If the website is pending validation, the gateway shows a friendly waiting page. If it's broken or gone, it returns an appropriate error.
## Content addressing vs traditional hosting
| Traditional hosting | Pinner website hosting |
|---------------------|------------------------|
| Files live on a specific server | Files live on IPFS, a distributed network |
| Server URL identifies the content | CID identifies the content |
| Updating means replacing files on the server | Updating means uploading new files and pointing the domain at the new CID |
| Server downtime = site downtime | No single server to go down |
## Subdomains
If you just want to get something live quickly, Pinner provides `*.pinner.xyz` subdomains. No DNS configuration needed; the subdomain is set up automatically when you create the website.
Custom domains require DNS validation and DNS configuration, but give you full branding control.
## Updating your site
With **IPNS targeting** (the default for Pinner-managed DNS), you publish the new CID under the same IPNS name. Pinner automatically updates the IPNS record, and the domain resolves to the new content. This is the recommended approach for sites that change frequently.
With **direct IPFS targeting**, you update content by uploading new files and updating the website's target hash to the new CID. The DNSLink record must also be updated to point at the new CID. The old CID stays on IPFS until you unpin it.
See [IPNS](/hosting/concepts/ipns) for more on the IPNS approach.
# IPNS
IPNS (InterPlanetary Name System) is a naming system for IPFS. It lets you associate a stable name with a Content Identifier (CID), and update that association over time. For website hosting, this means you can update your site's content without changing the domain configuration.
## The problem IPNS solves
IPFS uses content addressing: a CID is derived from the content itself. Changed content means a new CID. This is a strength (integrity) but a challenge for websites: every time you update your site, the CID changes.
Without IPNS, updating a website looks like this:
1. Upload new content → get new CID
2. Update the website record to point at the new CID
3. Wait for DNS to propagate (if applicable)
With IPNS, step 2 goes away:
1. Upload new content → get new CID
2. Publish (or republish) the IPNS name to point at the new CID
3. The domain still points at the same IPNS name, which now resolves to the new CID
## How IPNS works on Pinner
### Keys
An IPNS key is an Ed25519 cryptographic keypair. Every key has two identifiers:
* **IPNS name**: the public key encoded as a CIDv1 with the `libp2p-key` codec (e.g. `k51qzi5...`). This is the stable name you use in DNSLink records and gateways.
* **Peer ID**: the base36-encoded libp2p peer identity (e.g. `12D3KooW...`). This is used internally for publishing and resolving.
Both are derived from the same public key. You can use either to refer to the key, but the IPNS name (`k51...`) is what appears in `/ipns/` paths.
When you create a key, Pinner returns:
* `id`: the numeric ID used in subsequent API calls
* `name`: the human-readable label you assigned
* `ipns_name`: the public IPNS name (e.g. `k51qzi5...`)
* `peer_id`: the libp2p peer ID (e.g. `12D3KooW...`)
### Creating and importing keys
You can **create** a new Ed25519 keypair on Pinner, or **import** an existing private key (base64-encoded). Importing lets you reuse a key that was generated elsewhere, for example, migrating from another IPFS node.
Pinner also supports **exporting** a key's private key so you can back it up or use it on another node.
### Publishing
Publishing creates a signed IPNS record that associates an IPNS name with a CID. You specify the key ID and the target CID. You can optionally set a TTL (time-to-live) for the record.
Each publish increments the IPNS sequence number and sets a validity period. The IPNS name now resolves to that CID. Publish again with a different CID; same IPNS name, different CID. The domain follows automatically.
### Resolving
Anyone can resolve an IPNS name to find the current CID. Resolution returns the CID the IPNS name currently points to, along with the record's sequence number, expiration time, and whether the record has expired.
### Republishing
IPNS records have a validity period (end-of-life). Republishing re-signs and re-broadcasts the record to extend its lifetime. Pinner also runs a **background republisher** that automatically republishes all keys with a known published CID on a configurable interval (default: every hour). You rarely need to republish manually.
## Auto-created IPNS keys for websites
When you create a website with `target_type=ipns` and provide a CID as the `target_hash`, Pinner automatically:
1. Creates an IPNS key named `{domain}-auto`
2. Publishes the CID to that key
3. Links the key to the website
This means you can point a website at an IPNS name without manually creating and publishing a key first. If a key with that name already exists, Pinner reuses it and publishes the new CID.
:::tip
The auto-created key is linked to the website via `ipns_key_id`. Pinner uses this link to resolve the website's **active CID**, the currently-published content, separately from the IPNS target hash.
:::
## IPNS vs direct IPFS for websites
| | Direct IPFS | IPNS |
|---|-----------|------|
| **Update method** | Change website `target_hash` | Republish IPNS name |
| **Domain config changes** | Yes, every update | No, set once |
| **Resolution speed** | One hop (CID direct) | Two hops (IPNS → CID) |
| **Best for** | Rarely updated sites | Frequently updated sites, CI/CD |
The resolution overhead of IPNS is small, typically under a second on Pinner's infrastructure. For most sites, it's not noticeable.
## IPNS key lifecycle
```
Create key → Publish CID → (update) → Republish CID → ... → Delete key
```
* **Create**: Generate an Ed25519 keypair on Pinner, or import an existing key
* **Publish**: Sign an IPNS record associating the key's name with a CID
* **Republish**: Re-sign the record to extend its validity (automatic via background republisher)
* **Delete**: Soft-delete the key
:::warning
Pinner prevents you from deleting an IPNS key that is referenced by an active website. You must update or remove the website first. If you manage to delete a key that a domain depends on, that domain will stop resolving.
:::
# SSL
Pinner provisions and renews SSL certificates for your custom domains automatically, using Caddy's On-Demand TLS with Let's Encrypt. You don't need to buy certificates, configure web servers, or set up renewal cron jobs.
## How it works
1. You add a CNAME or A record pointing your domain at Pinner's infrastructure
2. When the first HTTPS request arrives, Caddy checks if the domain is authorized by querying the gateway's `/allowed` endpoint
3. The gateway validates the domain: it checks the DNSLink record and confirms the website is active in the portal
4. If the domain passes validation, Caddy requests a certificate from Let's Encrypt via the HTTP-01 ACME challenge
5. Caddy completes the ACME challenge (served on port 80) and obtains the certificate
6. Caddy calls back to the portal's internal webhook to update the SSL status to `ready`
7. Your site is served over HTTPS
You don't touch any of this. The entire process runs in the background.
### Prerequisites
For SSL provisioning to work:
* **Port 80** must be accessible from the internet (required for the HTTP-01 ACME challenge)
* **Port 443** must be accessible for HTTPS traffic
* Your domain's DNSLink record (`_dnslink.yourdomain.com`) must be properly configured
* Your website must be in `active` status in the portal
## SSL states
SSL moves through these states:
```
pending → issuing → ready
↘ failed
```
| State | What's happening |
|-------|-----------------|
| `pending` | Waiting to start: the domain hasn't been validated or no HTTPS request has triggered certificate issuance yet |
| `issuing` | Caddy is obtaining the certificate from Let's Encrypt via the ACME protocol |
| `ready` | Certificate issued and serving HTTPS traffic |
| `failed` | Provisioning failed; the `error` field on the SSL status has details |
There is no separate `valid` or `error` state. A `failed` state includes an error message explaining what went wrong.
## Checking status
You can check SSL status through the SDK. The `GetSSLStatus` method returns the current state, and the `WaitForSSLStatusReady` method polls until the certificate reaches `ready` or `failed`.
```go
// Check current status
resp, err := client.Websites().GetSSLStatus(ctx, "example.com")
fmt.Println("SSL status:", resp.Ssl.Status)
// Wait until settled (ready or failed)
status, err := client.Websites().WaitForSSLStatusReady(ctx, "example.com")
```
The response also includes `issued_at` and `last_updated_at` timestamps when available.
## Renewal
Caddy automatically renews certificates before they expire (typically 30 days before). You don't need to do anything; renewal happens automatically in the background. The portal tracks the renewal via the same Caddy webhook callback, updating `last_updated_at`.
If a previously valid certificate for a soft-deleted website is still within its validity period, recreating the website for the same domain will inherit the `ready` SSL status automatically.
## DNS validation token
Before SSL can be provisioned, your website must be validated. When you create a website, Pinner generates a validation token and tells you to add a TXT record like:
```
pinner-verify. TXT "pinner-verify="
```
The token has an expiry time. If it expires, Pinner generates a new one automatically when you re-validate.
## Troubleshooting
### SSL stays in `pending`
No HTTPS request has triggered certificate issuance yet, or the domain hasn't been validated. Ensure:
1. Your website is in `active` status (run DNS validation first)
2. Your DNS is pointing at Pinner's infrastructure (CNAME or A record)
3. The DNSLink record exists: `dig TXT _dnslink.yourdomain.com`
### SSL stays in `issuing`
The ACME challenge is in progress. Common causes for getting stuck:
* **Port 80 is not accessible**: The HTTP-01 challenge requires port 80 to be reachable from the internet
* **DNS hasn't propagated**: Let's Encrypt needs to resolve your domain to the correct IP
* **The gateway's `/allowed` endpoint rejected the domain**: Check that the DNSLink record and website status are valid
You can test the `/allowed` endpoint directly:
```bash
curl "http://localhost:8080/allowed?domain=yourdomain.com"
```
A `200` response means the domain is approved for certificate issuance.
### SSL fails with `failed`
Check the `error` field on the SSL status response. Common causes:
* **Port 80 not accessible from the internet**: Required for the HTTP-01 ACME challenge; check firewall rules
* **DNS not pointing at Pinner**: The CNAME or A record is missing or incorrect
* **Website is not active**: The gateway's `/allowed` check returns 400 when the website status isn't `active`
* **DNSLink record missing or incorrect**: The gateway validates the `_dnslink` TXT record before allowing certificate issuance
* **Let's Encrypt rate limits**: Too many failed issuance attempts; wait and retry (see [Let's Encrypt rate limits](https://letsencrypt.org/docs/rate-limits/))
### SSL works but the site isn't loading
SSL is only one piece. Check that:
1. Your website's target hash points at a valid, pinned CID
2. The content is actually accessible on IPFS
3. DNS is correctly pointing at Pinner's infrastructure
# DNS and Domains
DNS is the bridge between your domain name and Pinner's hosting. You configure DNS records at your registrar (or let Pinner manage DNS entirely), and Pinner's gateway serves your IPFS content when visitors hit your domain.
## Two ways to handle DNS
### Pinner-managed DNS (recommended)
The fastest way to get your site live. Enable Pinner DNS and delegate your nameservers; Pinner creates all the records automatically (validation, dnslink, gateway routing). No manual record management needed.
After creating or updating a website with `--dns-hosting`, the CLI shows the nameservers for delegation automatically. You can also check them at any time:
```bash
pinner websites config
```
The nameservers are also available from the website configuration API endpoint. The `nameservers` field lists the nameservers to set at your registrar.
The tradeoff: Pinner handles all DNS for that domain. If you need fine-grained control over other DNS records, you can still manage them through Pinner's DNS zone editor (see [Manage DNS Zones](/dns/how-to/manage-zones) and [Manage DNS Records](/dns/how-to/manage-records)).
### Bring your own DNS
You keep your existing DNS provider and add records that point to Pinner:
Record | Purpose | Value |
\--------|---------|-------|
TXT | Domain validation (ownership) | `validation_token` from website response (e.g. `pinner-verify=abc123def456`) |
TXT | dnslink (content mapping) | `dnslink=/ipfs/` or `dnslink=/ipns/` |
CNAME | Route subdomain traffic to Pinner | `gateway_domain` from website response |
ALIAS | Route root domain traffic to Pinner | `gateway_domain` from website response |
A | Root domain routing (if ALIAS not supported) | IP from Pinner configuration |
This is useful if you have existing DNS infrastructure or need to manage records that Pinner doesn't support.
After creating a website, the CLI shows the required DNS records. You can also view them with:
```bash
pinner websites get
```
## DNS records for website hosting
### Validation TXT record
Before Pinner serves your domain, you must prove you own it. Pinner assigns a validation token to each website. The website response provides two fields:
* `validation_token`: the full TXT record value (e.g. `pinner-verify=abc123def456`)
* `validation_record_host`: the hostname for the TXT record (e.g. `pinner-verify.yourdomain.com`)
Add a TXT record:
```
TXT ""
```
After adding the record, trigger validation and Pinner checks that the token matches. The token has an expiration time; if it expires, Pinner generates a new one automatically on your next validation attempt.
Validate from the CLI:
```bash
pinner websites validate
```
### dnslink TXT record
Pinner checks a `_dnslink` TXT record that maps your domain to your content. For IPFS targets:
```
_dnslink.yourdomain.com TXT "dnslink=/ipfs/"
```
For IPNS targets:
```
_dnslink.yourdomain.com TXT "dnslink=/ipns/"
```
### CNAME record (subdomain)
For `www.yourdomain.com` or other subdomains, use the `gateway_domain` from the website response:
```
www CNAME
```
### ALIAS record (root domain)
Root domains can use an ALIAS record (supported by most modern DNS providers) pointing to the gateway:
```
@ ALIAS
```
If your DNS provider doesn't support ALIAS records, use an A record instead:
```
@ A
```
### Nameserver delegation (Pinner-managed DNS)
If you're using Pinner's DNS hosting, update your domain registrar to use Pinner's nameservers:
```bash
pinner websites config
```
Or retrieve them from the website configuration API endpoint. Once propagated, Pinner controls all DNS for your domain.
## Supported DNS record types
When managing DNS zones through Pinner, you can create the following user-manageable record types:
Type | Description |
\------|-------------|
A | IPv4 address record |
AAAA | IPv6 address record |
ALIAS | Alias record (maps root domain to another name) |
CNAME | Canonical name record |
MX | Mail exchange record |
NS | Nameserver record |
SRV | Service record |
TXT | Text record |
SOA and NS records are managed automatically by PowerDNS and cannot be directly modified by users.
## DNS propagation
DNS changes aren't instant. Typical propagation times:
Change | Typical time |
\--------|-------------|
New records | 1–5 minutes |
Modified records | 5–30 minutes |
Nameserver changes | 1–48 hours |
Pinner's SSL provisioning starts only after DNS has propagated. If SSL stays in `pending`, your DNS records may not have propagated yet.
## DNS hosting setup flow
1. Enable DNS hosting on your website: `pinner websites create --cid --dns-hosting` (or `pinner websites update --dns-hosting`)
2. Pinner creates a DNS zone with the necessary records (dnslink TXT, validation TXT, and ALIAS pointing to the gateway)
3. Get Pinner's nameservers: `pinner websites config`
4. Change your domain's nameservers at your registrar to Pinner's nameservers
5. Wait for nameserver propagation
6. Validate: `pinner dns zones validate `
7. SSL provisions automatically
See [Manage DNS Zones](/dns/how-to/manage-zones) and [Manage DNS Records](/dns/how-to/manage-records) for CLI commands.
> **This page is auto-generated** from the [pinner-cli](https://github.com/LumeWeb/pinner-cli) source code. Do not edit directly.
A minimal, developer-focused CLI tool for pinning content to IPFS via the Pinner.xyz service.
## Install
See [Getting Started with the CLI](/ipfs/cli/getting-started) to install the Pinner CLI.
## Authentication
Most commands require authentication. Run `pinner setup` for the interactive wizard, or set `PINNER_AUTH_TOKEN` in your environment.
## Global Flags
| Flag | Alias | Description |
|------|-------|-------------|
| `--json` | | Output JSON instead of human-readable |
| `--verbose` | | Show detailed output |
| `--quiet` | | Suppress non-error output |
| `--unmask` | | Show sensitive data (tokens, passwords, secrets) unmasked |
| `--auth-token` | | Auth token to override config |
| `--secure` | | Use HTTPS for API connections |
## Environment Variables
| Variable | Description |
|----------|-------------|
| `PINNER_AUTH_TOKEN` | Auth token to override config |
| `PINNER_SECURE` | Use HTTPS for API connections |
| `PINNER_EMAIL` | Email address for login |
| `PINNER_PASSWORD` | Password for login (WARNING: insecure, prefer stdin or env var) |
| `PINNER_OTP` | OTP code for 2FA (6 digits) |
| `PINNER_MEMORY_LIMIT` | Memory limit for CAR generation in megabytes |
## Command Summary
### [Setup](/reference/cli/setup)
| Command | Description |
|---------|-------------|
| [`setup`](/reference/cli/setup#setup) | Run the interactive setup wizard to configure your Pinner.xyz CLI environment. This wizard will guide you through authentication and configuration. |
| [`auth`](/reference/cli/setup#auth) | Authenticate with the Pinner.xyz service. |
| [`register`](/reference/cli/setup#register) | Register a new user account on Pinner.xyz. |
| [`confirm-email`](/reference/cli/setup#confirm-email) | Confirm your email address using the verification token sent to your email. |
| [`account`](/reference/cli/setup#account) | Manage your Pinner.xyz account settings including 2FA configuration. |
### [Content](/reference/cli/content)
| Command | Description |
|---------|-------------|
| [`upload`](/reference/cli/content#upload) | Upload files or directories to IPFS via the Pinner.xyz service. Content is converted to CAR format before uploading. |
| [`download`](/reference/cli/content#download) | Download content from IPFS by CID and save it to the local filesystem. Supports CID paths (e.g., CID/path/to/file) to download a specific file from a directory. |
| [`cat`](/reference/cli/content#cat) | Stream the contents of an IPFS CID to stdout. Supports CID paths (e.g., CID/path/to/file) to cat a specific file from a directory. |
| [`ls`](/reference/cli/content#ls) | List the contents of a directory pinned on IPFS by CID. Shows file names, types, and sizes for each entry. Supports CID paths (e.g., CID/path/to/dir) to list nested directories. |
### [Pinning](/reference/cli/pinning)
| Command | Description |
|---------|-------------|
| [`pin`](/reference/cli/pinning#pin) | Shortcut for 'pinner pins add'. Pin existing IPFS content by CID. Multiple CIDs can be provided as arguments, read from a file using --file, or piped from stdin. |
| [`pins`](/reference/cli/pinning#pins) | Manage your pinned IPFS content with subcommands for adding, removing, listing, checking status, and updating pin metadata. |
| [`list`](/reference/cli/pinning#list) | Shortcut for 'pinner pins ls'. List your pinned content with optional filtering. |
| [`status`](/reference/cli/pinning#status) | Shortcut for 'pinner pins status'. Check whether a pin has completed. If the pin is not found, account operations are checked as a fallback. |
| [`unpin`](/reference/cli/pinning#unpin) | Shortcut for 'pinner pins rm'. Remove pins by CID or remove all pins. |
| [`metadata`](/reference/cli/pinning#metadata) | The 'metadata' command has been removed. Use 'pinner pins update' to update pin metadata instead |
### [Management](/reference/cli/websites)
| Command | Description |
|---------|-------------|
| [`point`](/reference/cli/websites#point) | Point an onchain/decentralized domain at IPFS content via IPNS. |
| [`unpoint`](/reference/cli/websites#unpoint) | Remove the IPNS key for an onchain/decentralized domain. The domain will no longer resolve to IPFS content. |
| [`operations`](/reference/cli/websites#operations) | View and monitor account operations such as uploads, pins, and other processing tasks. |
| [`dns`](/reference/cli/websites#dns) | Manage DNS zones and records for your domains. DNS hosting allows you to control DNS configuration for IPFS-hosted websites. |
| [`ipns`](/reference/cli/websites#ipns) | IPNS provides a mutable address scheme for IPFS content, allowing you to publish content under a stable name that you can update to point to new CIDs. |
| [`websites`](/reference/cli/websites#websites) | Manage websites for your IPFS content. Websites allow you to associate domain names with CIDs, making your content accessible through custom domains. |
### [System](/reference/cli/system)
| Command | Description |
|---------|-------------|
| [`config`](/reference/cli/system#config) | View or modify CLI configuration settings. |
| [`doctor`](/reference/cli/system#doctor) | Show diagnostic information about your pinner CLI environment, including version, OS details, configuration location, and limits. |
| [`bench`](/reference/cli/system#bench) | Run a benchmark by uploading random data (or a specified path) and tracking each stage of the pipeline: generate, upload, queued, pinning, pinned. |
| [`generate-docs`](/reference/cli/system#generate-docs) | Generate CLI documentation in markdown, man, or JSON format |
### [Admin](/reference/cli/admin)
| Command | Description |
|---------|-------------|
| [`admin`](/reference/cli/admin#admin) | Administrative operations for quota management, billing, and profiling. |
> **This page is auto-generated** from the [pinner-cli](https://github.com/LumeWeb/pinner-cli) source code. Do not edit directly.
## `setup`
```bash
pinner setup [flags]
```
Run the interactive setup wizard to configure your Pinner.xyz CLI environment. This wizard will guide you through authentication and configuration.
If you've already run setup, you can skip steps or reconfigure.
```bash
pinner setup
pinner setup --skip-auth
pinner setup --skip-config
pinner setup --reset
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--skip-auth` | | Skip authentication step |
| `--skip-config` | | Skip configuration step |
| `--reset` | | Reset configuration and start fresh |
| `--non-interactive` | | Run in non-interactive mode (skip wizard) |
## `auth`
```bash
pinner auth [token] [flags]
```
Authenticate with the Pinner.xyz service.
**Ways to authenticate:**
**1. Provide an auth token directly:** pinner auth ``
**2. Interactive login:** pinner auth (prompts for all inputs)
**3. Semi-interactive:** pinner auth --email user@example.com (prompts for password and OTP if needed)
**4. Non-interactive:** PINNER\_EMAIL=x PINNER\_PASSWORD=\*\*\* pinner auth
**5. Non-interactive with 2FA:** PINNER\_EMAIL=x PINNER\_PASSWORD=\*\*\* PINNER\_OTP=123456 pinner auth
**6. Secure non-interactive:** echo "pass" | pinner auth --email user@example.com
```bash
pinner auth eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
pinner auth --email user@example.com
pinner auth --email user@example.com --password mypass --otp-code 123456
PINNER_EMAIL=user@example.com PINNER_PASSWORD=mypass pinner auth
echo "mypass" | pinner auth --email user@example.com --key-name "my-laptop"
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--email` | | Email address for login \[env: `PINNER_EMAIL`] |
| `--password` | | Password for login (WARNING: insecure, prefer stdin or env var) \[env: `PINNER_PASSWORD`] |
| `--otp-code` | | OTP code for 2FA (6 digits) \[env: `PINNER_OTP`] |
| `--key-name` | | Custom name for created API key |
| `--no-create-key` | | Skip API key creation, save token directly |
| `--force` | | Overwrite existing auth token without confirmation |
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `status` | Check if you are currently authenticated with Pinner.xyz. |
### `status`
```bash
pinner auth status
```
Check if you are currently authenticated with Pinner.xyz.
This command verifies that your stored auth token is valid by making a request to the Pinner.xyz API.
```bash
pinner auth status
pinner auth status --json
pinner auth status --verbose
```
## `register`
```bash
pinner register [flags]
```
Register a new user account on Pinner.xyz.
After registration, you will need to confirm your email address using the 'confirm-email' command before you can authenticate.
```bash
# Interactive mode (prompts for all required fields)
pinner register
# Non-interactive with flags
pinner register --email user@example.com --first-name John --last-name Doe
# Mix: provide email, prompt for other fields
pinner register --email user@example.com
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--email` | | Email address |
| `--first-name` | | First name |
| `--last-name` | | Last name |
| `--password` | | Password (if not provided, you will be prompted) |
## `confirm-email`
```bash
pinner confirm-email [flags]
```
Confirm your email address using the verification token sent to your email.
After registering with 'pinner register', you will receive an email with a verification token. Use this command to confirm your email address.
```bash
pinner confirm-email --email user@example.com --token abc123def456
pinner confirm-email -e user@example.com -t abc123def456
After confirmation, authenticate with:
pinner auth --email user@example.com
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--email` | | Email address **(required)** |
| `--token` | | Verification token from email **(required)** |
## `account`
```bash
pinner account
```
Manage your Pinner.xyz account settings including 2FA configuration.
```bash
pinner account otp enable
pinner account otp enable --otp 123456
pinner account otp disable
pinner account otp disable --password mypassword
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `otp` | Enable or disable two-factor authentication (2FA) for your account. |
| `api-keys` | Manage API keys for your Pinner.xyz account. |
### `otp`
```bash
pinner account otp
```
Enable or disable two-factor authentication (2FA) for your account.
When enabling 2FA, you will receive a secret key that must be added to your authenticator app (e.g., Google Authenticator, Authy). You will then need to verify the setup with a code from your app
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `enable` | Enable 2FA for your account. This will: |
| `disable` | Disable 2FA for your account. This will: |
#### `enable`
```bash
pinner account otp enable [flags]
```
**Enable 2FA for your account. This will:**
1. Generate a new OTP secret
2. Display a QR code/secret key for your authenticator app
3. Prompt you to verify the setup with a code from your app
After successful verification, 2FA will be required for all future logins
| Flag | Alias | Description |
|------|-------|-------------|
| `--otp` | | OTP code to verify setup (for non-interactive mode) |
#### `disable`
```bash
pinner account otp disable [flags]
```
**Disable 2FA for your account. This will:**
1. Prompt for your password for verification
2. Remove 2FA requirement from your account
**WARNING:** This reduces your account security. Consider re-enabling 2FA
| Flag | Alias | Description |
|------|-------|-------------|
| `--password` | | Password for verification (WARNING: insecure, prefer stdin or prompt) \[env: `PINNER_PASSWORD`] |
### `api-keys`
```bash
pinner account api-keys
```
Manage API keys for your Pinner.xyz account.
API keys are used to authenticate with the Pinner.xyz service without exposing your login credentials. Each key has a unique token that can be used with --auth-token or the PINNER\_AUTH\_TOKEN environment variable.
```bash
pinner account api-keys list
pinner account api-keys list --search my-key
pinner account api-keys create my-key
pinner account api-keys delete my-key
pinner account api-keys delete 00000000-0000-0000-0000-000000000001
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all API keys for your account. |
| `create` | Create a new API key with the given name. |
| `delete` | Delete an API key by its UUID or name. |
#### `list`
```bash
pinner account api-keys list [flags]
```
List all API keys for your account.
Use --search to filter keys by name
| Flag | Alias | Description |
|------|-------|-------------|
| `--search` | | Search API keys by name |
#### `create`
```bash
pinner account api-keys create
```
Create a new API key with the given name.
The API key token will be displayed once. Save it securely — it cannot be retrieved later.
**This key can be used with:**
pinner auth --auth-token ``
PINNER\_AUTH\_TOKEN=`` pinner ``
#### `delete`
```bash
pinner account api-keys delete [flags]
```
Delete an API key by its UUID or name.
If the key being deleted is the one currently used for authentication, the command will be blocked unless --force is used. After deleting your current key, you must re-authenticate with 'pinner auth'
| Flag | Alias | Description |
|------|-------|-------------|
| `--force` | | Force deletion of the API key currently used for authentication |
> **This page is auto-generated** from the [pinner-cli](https://github.com/LumeWeb/pinner-cli) source code. Do not edit directly.
## `upload`
```bash
pinner upload [path] [flags]
```
Upload files or directories to IPFS via the Pinner.xyz service. Content is converted to CAR format before uploading.
```bash
pinner upload myfile.txt
pinner upload myfile.txt --name "my document"
pinner upload myfile.txt --no-wait
pinner upload /path/to/directory --name "project files"
pinner upload largefile.zip --memory-limit 500
pinner upload myfile.txt --dry-run
# Upload from stdin (pipe)
cat myfile.txt | pinner upload --name "my file"
echo "hello world" | pinner upload --name "greeting"
curl -s https://example.com/data | pinner upload --name "downloaded"
The output includes:
```
* CID: Content identifier for your uploaded content
* Gateway URL: Public URL to access your content
* Size: File size in human-readable format
* Time: Upload duration
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | Custom name for the pin |
| `--no-wait` | | Return immediately without waiting for pinning to complete |
| `--memory-limit` | | Memory limit for CAR generation in megabytes \[env: `PINNER_MEMORY_LIMIT`] |
| `--dry-run` | | Preview operations without making any changes |
| `--chunk-size` | | Chunk size in bytes for UnixFS file splitting (default: 1048576) |
| `--chunker` | | DAG layout strategy: balanced (default) or trickle |
| `--max-links` | | Maximum number of links per DAG node (default: 174) |
| `--meta` | | Set metadata as key=value (repeatable) |
## `download`
```bash
pinner download [/] [flags]
```
Download content from IPFS by CID and save it to the local filesystem. Supports CID paths (e.g., CID/path/to/file) to download a specific file from a directory.
For streaming content to stdout, use 'pinner cat' instead. For listing directory contents, use 'pinner ls' instead.
```bash
pinner download bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner download bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e/subdir/file.txt
pinner download bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e -o myfile.txt
pinner download bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e -o /path/to/dir/
pinner download bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e -o existing.txt --force
pinner download bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --dry-run
The output includes:
```
* CID: Content identifier of the downloaded file
* Path: Local file path where content was saved
* Size: File size in human-readable format
* Time: Download duration
| Flag | Alias | Description |
|------|-------|-------------|
| `--output` | | Output file or directory path (defaults to CID as filename in current directory) |
| `--force` | | Force operation without confirmation |
| `--dry-run` | | Preview operations without making any changes |
## `cat`
```bash
pinner cat [/]
```
Stream the contents of an IPFS CID to stdout. Supports CID paths (e.g., CID/path/to/file) to cat a specific file from a directory.
```bash
pinner cat bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner cat bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e/path/to/file.txt
pinner cat bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e > output.txt
pinner cat bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e | jq .
pinner cat bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e | gzip > data.gz
Note: This command outputs raw content to stdout. Use --verbose or redirect stderr for progress info
```
## `ls`
```bash
pinner ls [/] [flags]
```
List the contents of a directory pinned on IPFS by CID. Shows file names, types, and sizes for each entry. Supports CID paths (e.g., CID/path/to/dir) to list nested directories.
If the path targets a file, shows file metadata instead of erroring.
```bash
pinner ls bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner ls bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --json
pinner ls bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --limit 5
pinner ls bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e/subdir
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--limit` | | Maximum number of results to return |
> **This page is auto-generated** from the [pinner-cli](https://github.com/LumeWeb/pinner-cli) source code. Do not edit directly.
## `pin`
```bash
pinner pin [flags]
```
Shortcut for 'pinner pins add'. Pin existing IPFS content by CID. Multiple CIDs can be provided as arguments, read from a file using --file, or piped from stdin.
```bash
pinner pin bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner pin bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --name "my file"
pinner pin bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --no-wait
pinner pin bafybeig...abc bafybeig...def bafybeig...ghi --parallel 5
pinner pin --file cids.txt
echo "bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e" | pinner pin
pinner pin --file cids.txt --continue --parallel 10
pinner pin bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --dry-run
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | Custom name for the pin |
| `--no-wait` | | Return immediately without waiting for pinning to complete |
| `--file` | | Read CIDs from a file (one per line) |
| `--parallel` | | Number of parallel operations (default: 1) |
| `--continue` | | Continue processing even if some operations fail |
| `--dry-run` | | Preview operations without making any changes |
## `pins`
```bash
pinner pins
```
Manage your pinned IPFS content with subcommands for adding, removing, listing, checking status, and updating pin metadata.
```bash
pinner pins add bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --name "my file"
pinner pins add bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --meta owner=alice --meta env=prod
pinner pins rm bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --force
pinner pins ls --status pinned
pinner pins status bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner pins update bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --meta owner=bob
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `add` | Pin content that is already on IPFS by providing its CID. Optionally set metadata key-value pairs at pin time using --meta. |
| `rm` | Remove a pin by its CID, or remove all pins with --all. |
| `ls` | List your pinned content with optional filtering. |
| `status` | Check whether a pin has completed. If the pin is not found, account operations are checked as a fallback. |
| `update` | Update name and/or metadata for a pin. Metadata is a set of key-value pairs (e.g., owner=alice, env=prod) that help you organize and filter pins. |
### `add`
```bash
pinner pins add [flags]
```
Pin content that is already on IPFS by providing its CID. Optionally set metadata key-value pairs at pin time using --meta.
```bash
pinner pins add bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner pins add bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --name "my file"
pinner pins add bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --no-wait
pinner pins add bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --meta owner=alice --meta env=prod
pinner pins add bafybeig...abc bafybeig...def bafybeig...ghi --parallel 5
pinner pins add --file cids.txt
pinner pins add bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --dry-run
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | Custom name for the pin |
| `--no-wait` | | Return immediately without waiting for pinning to complete |
| `--file` | | Read CIDs from a file (one per line) |
| `--parallel` | | Number of parallel operations (default: 1) |
| `--continue` | | Continue processing even if some operations fail |
| `--dry-run` | | Preview operations without making any changes |
| `--meta` | | Set metadata as key=value (repeatable) |
### `rm`
```bash
pinner pins rm [flags]
```
Remove a pin by its CID, or remove all pins with --all.
```bash
pinner pins rm bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner pins rm bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --force
pinner pins rm bafybeig...abc bafybeig...def bafybeig...ghi --force
pinner pins rm --file cids.txt --force
pinner pins rm bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --dry-run
pinner pins rm --all --force
pinner pins rm --all --status failed --force
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--force` | | Force operation without confirmation |
| `--file` | | Read CIDs from a file (one per line) |
| `--parallel` | | Number of parallel operations (default: 1) |
| `--continue` | | Continue processing even if some operations fail |
| `--dry-run` | | Preview operations without making any changes |
| `--status` | | Filter by pin status (queued, pinning, pinned, failed) |
| `--all` | | Remove all pins (requires --force) |
### `ls`
```bash
pinner pins ls [flags]
```
List your pinned content with optional filtering.
```bash
pinner pins ls
pinner pins ls --name "my-project"
pinner pins ls --status pinned
pinner pins ls --limit 20
pinner pins ls --watch
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | Filter by name |
| `--limit` | | Maximum number of results to return |
| `--status` | | Filter by pin status (queued, pinning, pinned, failed) |
| `--watch` | | Continuously monitor and update pin status (useful for watching uploads) |
### `status`
```bash
pinner pins status [flags]
```
Check whether a pin has completed. If the pin is not found, account operations are checked as a fallback.
```bash
pinner pins status bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner pins status bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --watch
pinner pins status bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--watch` | | Poll until settled |
### `update`
```bash
pinner pins update [flags]
```
Update name and/or metadata for a pin. Metadata is a set of key-value pairs (e.g., owner=alice, env=prod) that help you organize and filter pins.
```bash
pinner pins update bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --name "renamed"
pinner pins update bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --meta owner=alice --meta env=prod
pinner pins update bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --clear-meta
pinner pins update bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --clear-meta --meta fresh=start
pinner pins update bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --name "renamed" --meta env=prod
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | Rename the pin |
| `--meta` | | Set metadata as key=value (repeatable) |
| `--clear-meta` | | Clear all metadata |
| `--dry-run` | | Preview operations without making any changes |
## `list`
```bash
pinner list [flags]
```
Shortcut for 'pinner pins ls'. List your pinned content with optional filtering.
```bash
pinner list
pinner list --name "my-project"
pinner list --status pinned
pinner list --limit 20
pinner list --watch
pinner list --name backup --status failed --limit 50
echo "backup" | pinner list
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | Filter by name |
| `--limit` | | Maximum number of results to return |
| `--status` | | Filter by pin status (queued, pinning, pinned, failed) |
| `--watch` | | Continuously monitor and update pin status (useful for watching uploads) |
## `status`
```bash
pinner status [flags]
```
Shortcut for 'pinner pins status'. Check whether a pin has completed. If the pin is not found, account operations are checked as a fallback.
```bash
pinner status bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner status bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --watch
pinner status bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --json
echo "bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e" | pinner status
cat cids.txt | pinner status
Status values:
queued
```
* Pin is queued for processing
pinning
* Pin is being processed
pinned
* Pin is successfully pinned
failed
* Pin failed to pin
**Operation status values (shown when pin is not found):**
pending
* Operation is queued
running
* Operation is in progress
completed - Operation finished successfully
failed
* Operation failed
error
* Operation encountered an error
| Flag | Alias | Description |
|------|-------|-------------|
| `--watch` | | Poll until settled |
## `unpin`
```bash
pinner unpin [flags]
```
Shortcut for 'pinner pins rm'. Remove pins by CID or remove all pins.
```bash
pinner unpin bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner unpin bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --force
pinner unpin bafybeig...abc bafybeig...def bafybeig...ghi --force
pinner unpin --file cids.txt --force
pinner unpin --file cids.txt --force --parallel 5 --continue
pinner unpin bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --dry-run
pinner unpin all --force
pinner unpin all --force --status failed --dry-run
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--force` | | Force operation without confirmation |
| `--file` | | Read CIDs from a file (one per line) |
| `--parallel` | | Number of parallel operations (default: 1) |
| `--continue` | | Continue processing even if some operations fail |
| `--dry-run` | | Preview operations without making any changes |
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `all` | Remove all pinned content. This is a destructive operation with safety guards. |
### `all`
```bash
pinner unpin all [flags]
```
Remove all pinned content. This is a destructive operation with safety guards.
**This command requires two explicit confirmations:** 1. The --force flag to acknowledge the destructive nature 2. An interactive prompt requiring you to type the exact number of pins
For non-interactive use (scripts, CI), use --yes to accept the safety prompt. --force is always required regardless of --yes.
```bash
pinner unpin all --force
pinner unpin all --force --status failed
pinner unpin all --force --parallel 5 --continue
pinner unpin all --force --dry-run
pinner unpin all --force --yes
pinner unpin all --force --status queued --dry-run
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--force` | | Force operation without confirmation |
| `--status` | | Filter by pin status (queued, pinning, pinned, failed) |
| `--parallel` | | Number of parallel operations (default: 1) |
| `--continue` | | Continue processing even if some operations fail |
| `--dry-run` | | Preview operations without making any changes |
## `metadata`
```bash
pinner metadata
```
The 'metadata' command has been removed. Use 'pinner pins update' to update pin metadata instead
> **This page is auto-generated** from the [pinner-cli](https://github.com/LumeWeb/pinner-cli) source code. Do not edit directly.
## `point`
```bash
pinner point [flags]
```
Point an onchain/decentralized domain at IPFS content via IPNS.
The command is idempotent. If an IPNS key for this domain already exists, it reuses the key and republishes the new CID.
```bash
pinner point vitalik.eth --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner point vitalik.eth --cid bafybeig...updated
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--cid` | | CID to point the name at **(required)** |
## `unpoint`
```bash
pinner unpoint
```
Remove the IPNS key for an onchain/decentralized domain. The domain will no longer resolve to IPFS content.
```bash
pinner unpoint vitalik.eth
```
## `operations`
```bash
pinner operations
```
View and monitor account operations such as uploads, pins, and other processing tasks.
**Operations track server-side processing of your requests. Each operation has a status:** - pending
* Operation is queued - running
* Operation is in progress - completed - Operation finished successfully - failed
* Operation failed - error
* Operation encountered an error
```bash
pinner operations list
pinner operations list --status running
pinner operations list --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --limit 20
pinner operations get 42
pinner operations get 42 --watch
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List your account operations with optional filtering. Operations track server-side processing of your requests (uploads, pins, etc.). |
| `get` | Get detailed information about a specific operation. |
### `list`
```bash
pinner operations list [flags]
```
List your account operations with optional filtering. Operations track server-side processing of your requests (uploads, pins, etc.).
```bash
pinner operations list
pinner operations list --status running
pinner operations list --operation upload
pinner operations list --protocol ipfs
pinner operations list --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner operations list --limit 20
pinner operations list --watch
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--status` | | Filter by status (pending, running, completed, failed, error) |
| `--operation` | | Filter by operation type (e.g., upload, pin) |
| `--protocol` | | Filter by protocol (e.g., ipfs) |
| `--cid` | | Filter by CID |
| `--limit` | | Maximum number of results to return |
| `--watch` | | Continuously monitor and update pin status (useful for watching uploads) |
### `get`
```bash
pinner operations get [flags]
```
Get detailed information about a specific operation.
```bash
pinner operations get 42
pinner operations get 42 --watch
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--watch` | | Wait for the operation to complete |
## `dns`
```bash
pinner dns
```
Manage DNS zones and records for your domains. DNS hosting allows you to control DNS configuration for IPFS-hosted websites.
**Zone operations:**
* List all DNS zones
* Create a new DNS zone
* Get zone details
* Delete a DNS zone
**Record operations:**
* List DNS records for a zone
* Create DNS records
* Get record details
* Update DNS records
* Delete DNS records
```bash
pinner dns zones list
pinner dns zones create --domain example.com
pinner dns zones get example.com
pinner dns zones delete example.com
pinner dns records list example.com
pinner dns records create example.com --name www --type CNAME --content example.com
pinner dns records delete example.com --name www --type CNAME
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `zones` | Manage DNS zones for your domains. |
| `records` | Manage DNS records for zones. |
### `zones`
```bash
pinner dns zones
```
Manage DNS zones for your domains.
```bash
pinner dns zones list
pinner dns zones list --status active
pinner dns zones create --domain example.com
pinner dns zones create --domain example.com --nameservers ns1.example.com,ns2.example.com
pinner dns zones get example.com
pinner dns zones delete example.com
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all DNS zones for the authenticated user. |
| `create` | Create a new DNS zone for a domain. |
| `get` | Get details of a specific DNS zone. |
| `delete` | Delete a DNS zone and all its records. |
| `validate` | Validate that a DNS zone's nameservers are properly delegated. This checks that the domain's nameservers point to the expected Pinner.xyz nameservers. |
#### `list`
```bash
pinner dns zones list
```
List all DNS zones for the authenticated user.
```bash
pinner dns zones list
pinner dns zones list --json
```
#### `create`
```bash
pinner dns zones create [flags]
```
Create a new DNS zone for a domain.
```bash
pinner dns zones create --domain example.com
pinner dns zones create --domain example.com --nameservers ns1.example.com,ns2.example.com
pinner dns zones create --domain example.com --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--domain` | | Domain name for the website **(required)** |
| `--nameservers` | | Comma-separated list of nameservers (e.g., ns1.example.com,ns2.example.com) |
#### `get`
```bash
pinner dns zones get
```
Get details of a specific DNS zone.
```bash
pinner dns zones get example.com
pinner dns zones get example.com --json
```
#### `delete`
```bash
pinner dns zones delete
```
Delete a DNS zone and all its records.
```bash
pinner dns zones delete example.com
```
#### `validate`
```bash
pinner dns zones validate
```
Validate that a DNS zone's nameservers are properly delegated. This checks that the domain's nameservers point to the expected Pinner.xyz nameservers.
```bash
pinner dns zones validate example.com
pinner dns zones validate example.com --json
```
### `records`
```bash
pinner dns records
```
Manage DNS records for zones.
```bash
pinner dns records list example.com
pinner dns records create example.com --name www --type CNAME --content example.com
pinner dns records get example.com --name www --type CNAME
pinner dns records update example.com --name www --type CNAME --content new.example.com
pinner dns records delete example.com --name www --type CNAME
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all DNS records for a specific zone. |
| `create` | Create a DNS record in the specified zone. |
| `get` | Get details of a specific DNS record. |
| `update` | Update an existing DNS record. |
| `delete` | Delete a DNS record from a zone. |
#### `list`
```bash
pinner dns records list
```
List all DNS records for a specific zone.
```bash
pinner dns records list example.com
pinner dns records list example.com --json
```
#### `create`
```bash
pinner dns records create [flags]
```
Create a DNS record in the specified zone.
```bash
pinner dns records create example.com --name www --type CNAME --content example.com
pinner dns records create example.com --name _dnslink --type TXT --content "/ipfs/bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e" --ttl 3600
pinner dns records create example.com --name @ --type A --content 192.168.1.1 --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | DNS record name |
| `--type` | | DNS record type (A, AAAA, CNAME, TXT, MX, NS, etc.) |
| `--content` | | DNS record content |
| `--ttl` | | DNS record TTL in seconds (default: 3600) |
| `--disabled` | | Disable the DNS record |
#### `get`
```bash
pinner dns records get [flags]
```
Get details of a specific DNS record.
```bash
pinner dns records get example.com --name www --type CNAME
pinner dns records get example.com --name www --type CNAME --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | DNS record name **(required)** |
| `--type` | | DNS record type (A, AAAA, CNAME, TXT, MX, NS, etc.) **(required)** |
#### `update`
```bash
pinner dns records update [flags]
```
Update an existing DNS record.
```bash
pinner dns records update example.com --name www --type CNAME --content new.example.com
pinner dns records update example.com --name www --type CNAME --content new.example.com --ttl 7200
pinner dns records update example.com --name www --type CNAME --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | DNS record name **(required)** |
| `--type` | | DNS record type (A, AAAA, CNAME, TXT, MX, NS, etc.) **(required)** |
| `--content` | | DNS record content |
| `--ttl` | | DNS record TTL in seconds (default: 3600) |
| `--disabled` | | Disable the DNS record |
#### `delete`
```bash
pinner dns records delete [flags]
```
Delete a DNS record from a zone.
```bash
pinner dns records delete example.com --name www --type CNAME
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | DNS record name **(required)** |
| `--type` | | DNS record type (A, AAAA, CNAME, TXT, MX, NS, etc.) **(required)** |
## `ipns`
```bash
pinner ipns
```
IPNS provides a mutable address scheme for IPFS content, allowing you to publish content under a stable name that you can update to point to new CIDs.
**IPNS operations include:**
* Managing IPNS keys (create, list, get, delete)
* Publishing CIDs to IPNS names
* Republishing IPNS records
* Resolving IPNS names to their target CIDs
**Key names and numeric IDs are interchangeable. For example:**
**pinner ipns keys get my-key is equivalent to:**
pinner ipns keys get 1
```bash
pinner ipns keys list
pinner ipns keys create my-key
pinner ipns keys get my-key
pinner ipns keys delete my-key
pinner ipns publish bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --key-name my-key
pinner ipns republish my-key
pinner ipns resolve k51qzi5uqu5djx
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `keys` | Manage your IPNS keys. Keys are used to publish content under stable IPNS names that you can update to point to new CIDs. |
| `publish` | Publish a CID to an IPNS key, making it resolvable via the IPNS name. After publishing, the IPNS name will resolve to the specified CID. |
| `republish` | Republish the IPNS record for a specific key. This refreshes the record on the network, extending its validity. |
| `resolve` | Resolve an IPNS name to its target CID. This shows which content the IPNS name currently points to. |
### `keys`
```bash
pinner ipns keys
```
Manage your IPNS keys. Keys are used to publish content under stable IPNS names that you can update to point to new CIDs.
Key names and numeric IDs are interchangeable.
```bash
pinner ipns keys list
pinner ipns keys create my-key
pinner ipns keys get my-key
pinner ipns keys delete my-key
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all IPNS keys for the authenticated user. |
| `create` | Create a new IPNS key with the given name. Use the key to publish content under a stable IPNS name. |
| `get` | Get details of a specific IPNS key by its name or ID. |
| `delete` | Delete an IPNS key by its name or ID. This operation is irreversible. |
#### `list`
```bash
pinner ipns keys list
```
List all IPNS keys for the authenticated user.
```bash
pinner ipns keys list
pinner ipns keys list --json
```
#### `create`
```bash
pinner ipns keys create [flags]
```
Create a new IPNS key with the given name. Use the key to publish content under a stable IPNS name.
```bash
pinner ipns keys create my-key
pinner ipns keys create my-key --key ``
pinner ipns keys create my-key --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--key` | | Private key to import (optional, generates a new key if not provided) |
#### `get`
```bash
pinner ipns keys get
```
Get details of a specific IPNS key by its name or ID.
```bash
pinner ipns keys get my-key
pinner ipns keys get 1
pinner ipns keys get my-key --json
```
#### `delete`
```bash
pinner ipns keys delete
```
Delete an IPNS key by its name or ID. This operation is irreversible.
```bash
pinner ipns keys delete my-key
pinner ipns keys delete 1
```
### `publish`
```bash
pinner ipns publish [flags]
```
Publish a CID to an IPNS key, making it resolvable via the IPNS name. After publishing, the IPNS name will resolve to the specified CID.
Specify the key by name or numeric ID.
```bash
pinner ipns publish bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --key-name my-key
pinner ipns publish bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --key-name my-key --ttl 24h
pinner ipns publish bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --key-name 1 --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--key-name` | | Name or ID of the IPNS key to publish to **(required)** |
| `--ttl` | | Time-to-live for the IPNS record (e.g., 24h, 7d) |
| `--wait` | | Wait for the pinning operation to complete before returning |
### `republish`
```bash
pinner ipns republish
```
Republish the IPNS record for a specific key. This refreshes the record on the network, extending its validity.
Specify the key by name or numeric ID.
```bash
pinner ipns republish my-key
pinner ipns republish 1
pinner ipns republish my-key --json
```
### `resolve`
```bash
pinner ipns resolve
```
Resolve an IPNS name to its target CID. This shows which content the IPNS name currently points to.
```bash
pinner ipns resolve k51qzi5uqu5djx...
pinner ipns resolve k51qzi5uqu5djx... --json
```
## `websites`
```bash
pinner websites
```
Manage websites for your IPFS content. Websites allow you to associate domain names with CIDs, making your content accessible through custom domains.
**Website operations include:**
* List all websites
* Create a new website
* Get website details
* Update website configuration
* Delete a website
* Validate website configuration
```bash
pinner websites list
pinner websites create example.com --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner websites get example.com
pinner websites update example.com --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner websites delete example.com
pinner websites validate example.com
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all websites for the authenticated user. |
| `create` | Create a new website with the specified domain and target CID. |
| `get` | Get details of a specific website by domain. |
| `update` | Update an existing website with new CID, target type, or domain rename. |
| `enable-ipns` | Convert a website from IPFS to IPNS targeting. |
| `delete` | Delete a website by domain. This operation is irreversible. |
| `validate` | Validate a website by domain to check if DNS is properly configured. |
| `ssl` | View SSL certificate status for your websites. |
| `config` | Show the website hosting configuration including the gateway domain. |
| `wizard` | Launch an interactive wizard to create a new website step by step. |
### `list`
```bash
pinner websites list
```
List all websites for the authenticated user.
```bash
pinner websites list
pinner websites list --json
```
### `create`
```bash
pinner websites create [flags]
```
Create a new website with the specified domain and target CID.
```bash
pinner websites create example.com --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner websites create example.com --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --target-type ipfs
pinner websites create example.com --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --dns-hosting
pinner websites create example.com --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--cid` | | CID for the website **(required)** |
| `--target-type` | | Target type (ipfs, ipns, etc.) |
| `--dns-hosting` | | Enable DNS hosting for this website |
| `--no-dns-hosting` | | Disable DNS hosting for this website |
### `get`
```bash
pinner websites get
```
Get details of a specific website by domain.
```bash
pinner websites get example.com
pinner websites get example.com --json
```
### `update`
```bash
pinner websites update [flags]
```
Update an existing website with new CID, target type, or domain rename.
At least one of the optional fields must be provided to update the website.
When --target-type is set to "ipns" without --cid, the website will be converted from IPFS to IPNS targeting (an IPNS key is auto-created and the current CID is published to it).
When --target-type is "ipns" and --cid is a regular IPFS CID (not a peer ID), an IPNS key is auto-created and that CID is published to it.
```bash
pinner websites update example.com --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --target-type ipfs
pinner websites update example.com --target-type ipns
pinner websites update example.com --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e --target-type ipns
pinner websites update example.com --dns-hosting
pinner websites update example.com --no-dns-hosting
pinner websites update example.com --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--domain` | | Domain name for the website |
| `--cid` | | CID for the website |
| `--target-type` | | Target type (ipfs, ipns, etc.) |
| `--dns-hosting` | | Enable DNS hosting for this website |
| `--no-dns-hosting` | | Disable DNS hosting for this website |
### `enable-ipns`
```bash
pinner websites enable-ipns [flags]
```
Convert a website from IPFS to IPNS targeting.
An IPNS key will be auto-created and the current CID will be published to it. This enables content-addressed updates without changing the domain's DNS records.
If --cid is provided, the IPNS key will publish that CID instead of the current one.
```bash
pinner websites enable-ipns example.com
pinner websites enable-ipns example.com --cid bafybeigqaforwjgcx45jnh7dgyfgqqm2lei4hurrrnsizrpgyxz3egtd7e
pinner websites enable-ipns example.com --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--cid` | | CID for the website |
### `delete`
```bash
pinner websites delete
```
Delete a website by domain. This operation is irreversible.
```bash
pinner websites delete example.com
pinner websites delete example.com --json
```
### `validate`
```bash
pinner websites validate
```
Validate a website by domain to check if DNS is properly configured.
```bash
pinner websites validate example.com
pinner websites validate example.com --json
```
### `ssl`
```bash
pinner websites ssl
```
View SSL certificate status for your websites.
**SSL operations include:**
* Check SSL certificate status for a domain
* Monitor SSL status changes in real-time
```bash
pinner websites ssl status example.com
pinner websites ssl status example.com --json
pinner websites ssl status example.com --watch
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `status` | Get SSL certificate status for a website domain. |
#### `status`
```bash
pinner websites ssl status [flags]
```
Get SSL certificate status for a website domain.
**This command retrieves the current SSL certificate status including:**
* Certificate status (active, pending, error, etc.)
* Certificate issuance date
* Last update timestamp
* Any error messages
```bash
pinner websites ssl status example.com
pinner websites ssl status example.com --json
pinner websites ssl status example.com --watch
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--watch` | | Watch for SSL status changes |
### `config`
```bash
pinner websites config
```
Show the website hosting configuration including the gateway domain.
Use this to find the gateway domain for setting up CNAME records with your DNS provider.
```bash
pinner websites config
pinner websites config --json
```
### `wizard`
```bash
pinner websites wizard
```
Launch an interactive wizard to create a new website step by step.
**The wizard will guide you through:**
1. Authentication check
2. Content source (CID or upload)
3. Target type (IPFS or IPNS)
4. Domain name
5. DNS mode (Pinner-managed or self-managed)
6. Website creation
7. DNS setup
8. Validation
```bash
pinner websites wizard
```
> **This page is auto-generated** from the [pinner-cli](https://github.com/LumeWeb/pinner-cli) source code. Do not edit directly.
## `admin`
```bash
pinner admin
```
Administrative operations for quota management, billing, and profiling.
These commands require administrative privileges and are intended for system administrators.
**Quota operations include:**
* List, create, update, delete quota plans
* Manage user quota allowances
* View system statistics
* Reconcile quotas and cleanup expired data
* Manage user quota configurations
**Billing operations include:**
* Manage billing credits
* View user balances
* Manage price lines and pricing plans
* Manage subscribers and subscriptions
**Profiling operations include:**
* Access Go runtime pprof profiles (heap, cpu, goroutine, etc.)
* Configure block and mutex profiling rates
* View profiling status
```bash
pinner admin quota plans list
pinner admin quota allowances list
pinner admin billing credits list
pinner admin billing subscribers list
pinner admin pprof status
pinner admin pprof heap > heap.prof
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `quota` | Manage quota plans, allowances, and user configurations. |
| `billing` | Manage billing credits, price lines, pricing plans, and subscriptions. |
| `websites` | Administrative operations for IPFS websites. |
| `pprof` | Access Go runtime profiling data via pprof endpoints. |
### `quota`
```bash
pinner admin quota
```
Manage quota plans, allowances, and user configurations.
**Quota operations include:**
* Plan management (list, create, update, delete)
* Allowance management (list, create, update, delete)
* User config management
* System statistics and reconciliation
```bash
pinner admin quota plans list
pinner admin quota plans get ``
pinner admin quota allowances list
pinner admin quota stats
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `plans` | List, create, update, and delete quota plans. |
| `allowances` | List, create, update, and delete quota allowances. |
| `user-configs` | List and update user quota configurations. |
| `stats` | View quota system statistics. |
| `reconcile` | Reconcile quota data for all users or a specific user. |
| `cleanup` | Cleanup expired quota data older than the specified retention period. |
#### `plans`
```bash
pinner admin quota plans
```
List, create, update, and delete quota plans.
```bash
pinner admin quota plans list
pinner admin quota plans get ``
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all available quota plans. |
| `get` | Get details of a specific quota plan. |
| `create` | Create a new quota plan with specified limits. |
| `update` | Update an existing quota plan. |
| `delete` | Delete a quota plan by ID. |
| `set-default` | Set a quota plan as the default for new users. |
#### `list`
```bash
pinner admin quota plans list
```
List all available quota plans.
```bash
pinner admin quota plans list
pinner admin quota plans list --json
```
#### `get`
```bash
pinner admin quota plans get
```
Get details of a specific quota plan.
```bash
pinner admin quota plans get ``
pinner admin quota plans get `` --json
```
#### `create`
```bash
pinner admin quota plans create [flags]
```
Create a new quota plan with specified limits.
```bash
pinner admin quota plans create --name "Pro" --upload 1000 --download 2000 --storage 5000
pinner admin quota plans create --name "Free" --is-active --is-default
pinner admin quota plans create --name "Basic" --description "Basic tier" --is-active
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | Plan name |
| `--description` | | Plan description |
| `--upload-limit` | | Upload limit (bytes) |
| `--download-limit` | | Download limit (bytes) |
| `--storage-limit` | | Storage limit (bytes) |
| `--window-type` | | Window type (ROLLING, DAY, WEEK, MONTH, YEAR, LIFETIME) |
| `--is-active` | | Mark plan as active |
| `--is-default` | | Set as default plan for new users |
#### `update`
```bash
pinner admin quota plans update [flags]
```
Update an existing quota plan.
```bash
pinner admin quota plans update `` --name "Updated Pro"
pinner admin quota plans update `` --is-active --is-default
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | Plan name |
| `--description` | | Plan description |
| `--upload-limit` | | Upload limit (bytes) |
| `--download-limit` | | Download limit (bytes) |
| `--storage-limit` | | Storage limit (bytes) |
| `--window-type` | | Window type (ROLLING, DAY, WEEK, MONTH, YEAR, LIFETIME) |
| `--is-active` | | Mark plan as active |
| `--is-default` | | Set as default plan for new users |
#### `delete`
```bash
pinner admin quota plans delete
```
Delete a quota plan by ID.
```bash
pinner admin quota plans delete ``
```
#### `set-default`
```bash
pinner admin quota plans set-default
```
Set a quota plan as the default for new users.
```bash
pinner admin quota plans set-default ``
```
#### `allowances`
```bash
pinner admin quota allowances
```
List, create, update, and delete quota allowances.
```bash
pinner admin quota allowances list
pinner admin quota allowances create --user-id 123 --type bonus
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all quota allowances. |
| `create` | Create a new quota allowance for a user. |
| `update` | Update an existing quota allowance. |
| `delete` | Delete a quota allowance by grant ID. |
#### `list`
```bash
pinner admin quota allowances list
```
List all quota allowances.
```bash
pinner admin quota allowances list
pinner admin quota allowances list --json
```
#### `create`
```bash
pinner admin quota allowances create [flags]
```
Create a new quota allowance for a user.
```bash
pinner admin quota allowances create --user-id 123 --source admin --type bonus --upload 1000
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--user-id` | | User ID |
| `--source` | | Allowance source |
| `--quota-type` | | Allowance type |
| `--upload-limit` | | Upload allowance (bytes) |
| `--download-limit` | | Download allowance (bytes) |
| `--storage-limit` | | Storage allowance (bytes) |
| `--expiry` | | Expiry in days from now |
#### `update`
```bash
pinner admin quota allowances update [flags]
```
Update an existing quota allowance.
```bash
pinner admin quota allowances update `` --upload-limit 2000
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--user-id` | | User ID |
| `--source` | | Allowance source |
| `--quota-type` | | Allowance type |
| `--upload-limit` | | Upload allowance (bytes) |
| `--download-limit` | | Download allowance (bytes) |
| `--storage-limit` | | Storage allowance (bytes) |
| `--expiry` | | Expiry in days from now |
#### `delete`
```bash
pinner admin quota allowances delete
```
Delete a quota allowance by grant ID.
```bash
pinner admin quota allowances delete ``
```
#### `user-configs`
```bash
pinner admin quota user-configs
```
List and update user quota configurations.
```bash
pinner admin quota user-configs list
pinner admin quota user-configs update --user-id 1 --plan-id 19
pinner admin quota user-configs reset ``
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all user quota configurations. |
| `update` | Update a user's quota configuration. |
| `reset` | Reset a user's quota plan to the default. |
#### `list`
```bash
pinner admin quota user-configs list
```
List all user quota configurations.
```bash
pinner admin quota user-configs list
pinner admin quota user-configs list --json
```
#### `update`
```bash
pinner admin quota user-configs update [flags]
```
Update a user's quota configuration.
```bash
pinner admin quota user-configs update --user-id 1 --plan-id 19
pinner admin quota user-configs update --user-id 1 --plan-id 19 --enforcement-policy HARD_LIMITS
pinner admin quota user-configs update --user-id 1 --upload-limit 5000 --download-limit 10000
pinner admin quota user-configs update --user-id 1 --plan-id 19 --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--user-id` | | User ID |
| `--plan-id` | | Quota plan ID to assign |
| `--enforcement-policy` | | Enforcement policy (HARD\_LIMITS, UNLIMITED, ALLOWANCE, THRESHOLD) |
| `--upload-limit` | | Upload limit override (bytes) |
| `--download-limit` | | Download limit override (bytes) |
| `--storage-limit` | | Storage limit override (bytes) |
| `--upload-threshold` | | Upload threshold override (bytes) |
| `--download-threshold` | | Download threshold override (bytes) |
| `--storage-threshold` | | Storage threshold override (bytes) |
| `--window-duration` | | Window duration override |
| `--window-start-hour` | | Window start hour override |
| `--window-timezone` | | Window timezone override |
| `--window-type` | | Window type override (ROLLING, DAY, WEEK, MONTH, YEAR, LIFETIME) |
#### `reset`
```bash
pinner admin quota user-configs reset
```
Reset a user's quota plan to the default.
```bash
pinner admin quota user-configs reset ``
```
#### `stats`
```bash
pinner admin quota stats
```
View quota system statistics.
```bash
pinner admin quota stats
pinner admin quota stats --json
```
#### `reconcile`
```bash
pinner admin quota reconcile [flags]
```
Reconcile quota data for all users or a specific user.
```bash
pinner admin quota reconcile
pinner admin quota reconcile --user-id 123
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--user-id` | | Specific user ID to reconcile (optional) |
#### `cleanup`
```bash
pinner admin quota cleanup [flags]
```
Cleanup expired quota data older than the specified retention period.
```bash
pinner admin quota cleanup --retention-days 90
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--retention-days` | | Retention period in days |
### `billing`
```bash
pinner admin billing
```
Manage billing credits, price lines, pricing plans, and subscriptions.
**Billing operations include:**
* Credit management (list, create, delete, restore, purge)
* User balance viewing
* Price line management
* Pricing plan and period management
* Subscriber and subscription management
```bash
pinner admin billing credits list
pinner admin billing price-lines list
pinner admin billing pricing-plans list
pinner admin billing subscribers list
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `overview` | Display an overview of billing entities and their relationships. |
| `credits` | Manage billing credits for users. |
| `price-lines` | List, create, update, and delete billing price lines. |
| `pricing-plans` | List, create, update, delete, and sync billing pricing plans. |
| `pricing-plan-periods` | List, create, update, and delete billing pricing plan periods. |
| `subscribers` | List, manage, and modify billing subscribers. |
#### `overview`
```bash
pinner admin billing overview
```
Display an overview of billing entities and their relationships.
Shows the data model hierarchy and current entity counts.
```bash
pinner admin billing overview
pinner admin billing overview --json
```
#### `credits`
```bash
pinner admin billing credits
```
Manage billing credits for users.
```bash
pinner admin billing credits list
pinner admin billing credits get ``
pinner admin billing credits create --user-id 123 --amount 100.00 --type manual --direction credit
pinner admin billing credits delete ``
pinner admin billing credits restore ``
pinner admin billing credits purge
pinner admin billing credits user-balance ``
pinner admin billing credits user-deleted-credits ``
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all billing credits with optional filtering. |
| `get` | Get details of a specific credit by its ID. |
| `create` | Create a new billing credit for a user. |
| `delete` | Soft-delete a credit by its ID. |
| `restore` | Restore a soft-deleted credit by its ID. |
| `purge` | Permanently delete soft-deleted credits older than specified duration. |
| `user-balance` | Get the current balance for a specific user. |
| `user-deleted-credits` | Get all soft-deleted credits for a specific user. |
#### `list`
```bash
pinner admin billing credits list [flags]
```
List all billing credits with optional filtering.
```bash
pinner admin billing credits list
pinner admin billing credits list --user-id 123
pinner admin billing credits list --direction credit
pinner admin billing credits list --type manual
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--user-id` | | Filter by user ID |
| `--direction` | | Filter by direction (credit, debit) |
| `--type` | | Filter by type |
#### `get`
```bash
pinner admin billing credits get
```
Get details of a specific credit by its ID.
```bash
pinner admin billing credits get ``
pinner admin billing credits get `` --json
```
#### `create`
```bash
pinner admin billing credits create [flags]
```
Create a new billing credit for a user.
```bash
pinner admin billing credits create --user-id 123 --amount 100.00 --type manual --direction credit
pinner admin billing credits create --user-id 123 --amount 50.00 --type promo --direction credit --description "Promotional credit"
pinner admin billing credits create --user-id 123 --amount 200.00 --type manual --direction debit --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--user-id` | | User ID to credit **(required)** |
| `--amount` | | Credit amount (as decimal string) **(required)** |
| `--type` | | Credit type (e.g., manual, promo, referral) **(required)** |
| `--direction` | | Direction (credit or debit) **(required)** |
| `--description` | | Credit description |
| `--reference-id` | | Reference ID for this credit |
| `--reference-type` | | Reference type for this credit |
#### `delete`
```bash
pinner admin billing credits delete
```
Soft-delete a credit by its ID.
```bash
pinner admin billing credits delete ``
pinner admin billing credits delete `` --json
```
#### `restore`
```bash
pinner admin billing credits restore
```
Restore a soft-deleted credit by its ID.
```bash
pinner admin billing credits restore ``
pinner admin billing credits restore `` --json
```
#### `purge`
```bash
pinner admin billing credits purge [flags]
```
Permanently delete soft-deleted credits older than specified duration.
```bash
pinner admin billing credits purge
pinner admin billing credits purge --older-than "30d"
pinner admin billing credits purge --older-than "7d" --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--older-than` | | Delete credits deleted more than this duration ago (e.g., 30d, 1w, 24h) |
#### `user-balance`
```bash
pinner admin billing credits user-balance
```
Get the current balance for a specific user.
```bash
pinner admin billing credits user-balance ``
pinner admin billing credits user-balance `` --json
```
#### `user-deleted-credits`
```bash
pinner admin billing credits user-deleted-credits [flags]
```
Get all soft-deleted credits for a specific user.
```bash
pinner admin billing credits user-deleted-credits ``
pinner admin billing credits user-deleted-credits `` --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--direction` | | Filter by direction |
| `--type` | | Filter by type |
#### `price-lines`
```bash
pinner admin billing price-lines
```
List, create, update, and delete billing price lines.
```bash
pinner admin billing price-lines list
pinner admin billing price-lines get ``
pinner admin billing price-lines create --name "Storage" --description "Storage pricing"
pinner admin billing price-lines update `` --name "Updated Storage"
pinner admin billing price-lines delete ``
pinner admin billing price-lines add-plan `` --plan-id `` --position 1
pinner admin billing price-lines delete-plan `` --plan-id ``
pinner admin billing price-lines update-plan-position `` --plan-id `` --position 2
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all billing price lines. |
| `get` | Get detailed information about a billing price line. |
| `create` | Create a new billing price line. |
| `update` | Update an existing price line. |
| `delete` | Delete a billing price line. |
| `add-plan` | Add a pricing plan to a price line. |
| `delete-plan` | Remove a pricing plan from a price line. |
| `update-plan-position` | Update the position of a pricing plan within a price line. |
#### `list`
```bash
pinner admin billing price-lines list
```
List all billing price lines.
```bash
pinner admin billing price-lines list
pinner admin billing price-lines list --json
```
#### `get`
```bash
pinner admin billing price-lines get
```
Get detailed information about a billing price line.
```bash
pinner admin billing price-lines get ``
pinner admin billing price-lines get `` --json
```
#### `create`
```bash
pinner admin billing price-lines create [flags]
```
Create a new billing price line.
```bash
pinner admin billing price-lines create --name "Storage" --description "Storage pricing"
pinner admin billing price-lines create --name "Bandwidth" --description "Monthly bandwidth" --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | Price line name **(required)** |
| `--description` | | Price line description |
| `--is-active` | | Mark price line as active |
| `--is-default` | | Mark as default price line |
#### `update`
```bash
pinner admin billing price-lines update [flags]
```
Update an existing price line.
```bash
pinner admin billing price-lines update `` --name "Updated Storage"
pinner admin billing price-lines update `` --description "New description" --is-active false --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | Price line name |
| `--description` | | Price line description |
| `--is-active` | | Mark price line as active |
| `--is-default` | | Mark as default price line |
#### `delete`
```bash
pinner admin billing price-lines delete
```
Delete a billing price line.
```bash
pinner admin billing price-lines delete ``
pinner admin billing price-lines delete `` --json
```
#### `add-plan`
```bash
pinner admin billing price-lines add-plan [flags]
```
Add a pricing plan to a price line.
If --position is omitted, the plan is appended to the end of the price line.
```bash
pinner admin billing price-lines add-plan `` --plan-id ``
pinner admin billing price-lines add-plan `` --plan-id `` --position 1
pinner admin billing price-lines add-plan `` --plan-id `` --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--plan-id` | | Pricing plan ID to add **(required)** |
| `--position` | | Position of the plan in the price line (auto-appended if omitted) |
#### `delete-plan`
```bash
pinner admin billing price-lines delete-plan [flags]
```
Remove a pricing plan from a price line.
```bash
pinner admin billing price-lines delete-plan `` --plan-id ``
pinner admin billing price-lines delete-plan `` --plan-id `` --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--plan-id` | | Pricing plan ID to remove **(required)** |
#### `update-plan-position`
```bash
pinner admin billing price-lines update-plan-position [flags]
```
Update the position of a pricing plan within a price line.
```bash
pinner admin billing price-lines update-plan-position `` --plan-id `` --position 1
pinner admin billing price-lines update-plan-position `` --plan-id `` --position 2 --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--plan-id` | | Pricing plan ID to reposition **(required)** |
| `--position` | | New position for the plan **(required)** |
#### `pricing-plans`
```bash
pinner admin billing pricing-plans
```
List, create, update, delete, and sync billing pricing plans.
```bash
pinner admin billing pricing-plans list
pinner admin billing pricing-plans sync ``
pinner admin billing pricing-plans sync-all
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all billing pricing plans. |
| `get` | Get details of a specific pricing plan. |
| `create` | Create a new billing pricing plan. |
| `update` | Update an existing billing pricing plan. |
| `delete` | Delete a pricing plan by ID. |
| `sync` | Trigger immediate synchronization of a specific pricing plan with the payment gateway. |
| `sync-all` | Trigger synchronization of all pricing plans with payment gateways. |
#### `list`
```bash
pinner admin billing pricing-plans list
```
List all billing pricing plans.
```bash
pinner admin billing pricing-plans list
pinner admin billing pricing-plans list --json
```
#### `get`
```bash
pinner admin billing pricing-plans get
```
Get details of a specific pricing plan.
**Arguments:**
``
The unique identifier of the pricing plan
```bash
pinner admin billing pricing-plans get 1
pinner admin billing pricing-plans get 5 --json
```
#### `create`
```bash
pinner admin billing pricing-plans create [flags]
```
Create a new billing pricing plan.
**Optionally create a pricing plan period in the same command by providing:**
**--quota-plan-id:** Creates a period with the specified quota plan
**--price:** Required with --quota-plan-id, price in USD
**--cadence:** Required with --quota-plan-id, cadence (monthly, yearly, rolling)
**--rolling-days:** Optional, for rolling cadence only
**--allow-free:** Optional, allows $0 price
```bash
pinner admin billing pricing-plans create --name "Pro Plan" --currency USD
pinner admin billing pricing-plans create --name "Basic" --currency USD --description "Basic plan" --is-active
pinner admin billing pricing-plans create --name "Premium" --currency USD --is-public --json
# Create plan with period in one command:
pinner admin billing pricing-plans create --name "Starter" --currency USD --quota-plan-id 1 --price 9.99 --cadence monthly
pinner admin billing pricing-plans create --name "Annual" --currency USD --quota-plan-id 2 --price 99.99 --cadence yearly
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | Pricing plan name **(required)** |
| `--currency` | | Currency code (e.g., USD, EUR) **(required)** |
| `--description` | | Plan description |
| `--is-active` | | Mark plan as active |
| `--is-public` | | Mark plan as public |
| `--priceline-id` | | Associated price line ID |
| `--quota-plan-id` | | Create a period: associated quota plan ID |
| `--price` | | Create a period: price in USD |
| `--cadence` | | Create a period: cadence (e.g., monthly, yearly, rolling) |
| `--rolling-days` | | Create a period: rolling days (for rolling cadence only) |
| `--allow-free` | | Create a period: allow $0 price |
#### `update`
```bash
pinner admin billing pricing-plans update [flags]
```
Update an existing billing pricing plan.
```bash
pinner admin billing pricing-plans update `` --name "Updated Pro"
pinner admin billing pricing-plans update `` --description "New desc" --is-active false --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--name` | | Pricing plan name |
| `--currency` | | Currency code (e.g., USD, EUR) |
| `--description` | | Plan description |
| `--is-active` | | Mark plan as active |
| `--is-public` | | Mark plan as public |
#### `delete`
```bash
pinner admin billing pricing-plans delete
```
Delete a pricing plan by ID.
```bash
pinner admin billing pricing-plans delete ``
pinner admin billing pricing-plans delete `` --json
```
#### `sync`
```bash
pinner admin billing pricing-plans sync
```
Trigger immediate synchronization of a specific pricing plan with the payment gateway.
This command syncs a single pricing plan to ensure the payment gateway has the latest configuration.
**Arguments:**
``
The unique identifier of the pricing plan to sync
```bash
pinner admin billing pricing-plans sync ``
pinner admin billing pricing-plans sync 123 --json
```
#### `sync-all`
```bash
pinner admin billing pricing-plans sync-all
```
Trigger synchronization of all pricing plans with payment gateways.
This command syncs all pricing plans to ensure the payment gateways have the latest configurations.
```bash
pinner admin billing pricing-plans sync-all
pinner admin billing pricing-plans sync-all --json
```
#### `pricing-plan-periods`
```bash
pinner admin billing pricing-plan-periods
```
List, create, update, and delete billing pricing plan periods.
pinner admin billing pricing-plan-periods list
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all billing pricing plan periods. |
| `get` | Get details of a specific pricing plan period. |
| `create` | Create a new billing pricing plan period. |
| `update` | Update an existing billing pricing plan period. |
| `delete` | Delete a pricing plan period by ID. |
#### `list`
```bash
pinner admin billing pricing-plan-periods list
```
List all billing pricing plan periods.
```bash
pinner admin billing pricing-plan-periods list
pinner admin billing pricing-plan-periods list --json
```
#### `get`
```bash
pinner admin billing pricing-plan-periods get
```
Get details of a specific pricing plan period.
```bash
pinner admin billing pricing-plan-periods get ``
pinner admin billing pricing-plan-periods get `` --json
```
#### `create`
```bash
pinner admin billing pricing-plan-periods create [flags]
```
Create a new billing pricing plan period.
```bash
pinner admin billing pricing-plan-periods create --plan-id 123 --price 9.99 --cadence monthly --quota-plan-id 1
pinner admin billing pricing-plan-periods create --plan-id 123 --price 99.99 --cadence yearly --quota-plan-id 1 --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--plan-id` | | Pricing plan ID **(required)** |
| `--price` | | Price in USD **(required)** |
| `--cadence` | | Cadence (e.g., monthly, yearly) **(required)** |
| `--quota-plan-id` | | Associated quota plan ID **(required)** |
| `--rolling-days` | | Rolling days (for rolling periods) |
| `--allow-free` | | Allow $0 price (free plan) |
#### `update`
```bash
pinner admin billing pricing-plan-periods update [flags]
```
Update an existing billing pricing plan period.
```bash
pinner admin billing pricing-plan-periods update `` --price 19.99
pinner admin billing pricing-plan-periods update `` --cadence yearly --json
```
| Flag | Alias | Description |
|------|-------|-------------|
| `--price` | | Price in USD |
| `--cadence` | | Cadence (e.g., monthly, yearly) |
| `--quota-plan-id` | | Associated quota plan ID |
| `--rolling-days` | | Rolling days (for rolling periods) |
| `--allow-free` | | Allow $0 price (free plan) |
#### `delete`
```bash
pinner admin billing pricing-plan-periods delete
```
Delete a pricing plan period by ID.
```bash
pinner admin billing pricing-plan-periods delete ``
pinner admin billing pricing-plan-periods delete `` --json
```
#### `subscribers`
```bash
pinner admin billing subscribers
```
List, manage, and modify billing subscribers.
```bash
pinner admin billing subscribers list
pinner admin billing subscribers get ``
pinner admin billing subscribers list-gateway ``
pinner admin billing subscribers list-user ``
pinner admin billing subscribers cancel --user-id 123
pinner admin billing subscribers abort-cancel --user-id 123
pinner admin billing subscribers change-plan --user-id 123 --plan-id "plan-abc"
pinner admin billing subscribers pause --user-id 123
pinner admin billing subscribers resume --user-id 123
```
**Subcommands:**
| Subcommand | Description |
|------------|-------------|
| `list` | List all billing subscribers across all gateways. |
| `get` | Get details of a specific subscriber. |
| `list-gateway` | List all subscribers for a specific gateway. |
| `list-user` | List all subscriptions for a specific user. |
| `cancel` | Cancel a user's subscription. |
| `abort-cancel` | Abort a scheduled subscription cancellation for a user. |
| `change-plan` | Change a user's subscription plan. |
| `pause` | Pause a user's subscription. |
| `resume` | Resume a paused subscription for a user. |
#### `list`
```bash
pinner admin billing subscribers list
```
List all billing subscribers across all gateways.
```bash
pinner admin billing subscribers list
pinner admin billing subscribers list --json
```
#### `get`
```bash
pinner admin billing subscribers get
```
Get details of a specific subscriber.
```bash
pinner admin billing subscribers get ``
pinner admin billing subscribers get `` --json
```
#### `list-gateway`
```bash
pinner admin billing subscribers list-gateway
```
List all subscribers for a specific gateway.
```bash
pinner admin billing subscribers list-gateway ``
pinner admin billing subscribers list-gateway `` --json
```
#### `list-user`
```bash
pinner admin billing subscribers list-user
```
List all subscriptions for a specific user.
```bash
pinner admin billing subscribers list-user ``
pinner admin billing subscribers list-user `