Welcome to Portal
Portal is a comprehensive toolkit for businesses to authenticate, get paid, and issue tickets to their customers without any intermediaries while maintaining full privacy.
What is Portal?
Portal leverages the power of Nostr protocol and the Lightning Network to provide:
- Decentralized Authentication: Secure user authentication without relying on centralized identity providers
- Lightning Payments: Process instant payments using Bitcoin's Lightning Network
- Privacy-First: No third parties, no data collection, full user privacy
- No Intermediaries: Direct peer-to-peer interactions between businesses and customers
- Ticket Issuance: Issue Cashu ecash tokens as tickets and vouchers for authorized users
Key Features
š Authentication
- Nostr-based user authentication using cryptographic keys
- Support for both main keys and delegated subkeys
- Secure challenge-response protocol
- No passwords, no email verification needed
š³ Payment Processing
- Single Payments: One-time Lightning Network payments
- Recurring Payments: Subscription-based payments with customizable schedules
- Real-time Status: Live payment status updates via WebSocket
- Currency Support: Millisats with exchange rate integration
- Cashu Support: Issue and accept Cashu ecash tokens
š« Ticket Issuance
- Issue Cashu tokens (ecash) as tickets to authenticated users
- Request and send Cashu tokens peer-to-peer
- Mint and burn tokens with your own mint
- Perfect for event tickets, access tokens, and vouchers
š¤ Profile Management
- Fetch user profiles from Nostr
- Update and publish service profiles
- NIP-05 identity verification support
š Session Management
- JWT token issuance and verification for API authentication
- Session tokens issued by user's wallet app
- Businesses verify JWT tokens without needing to issue them
- Perfect for stateless API authentication
š Multi-Platform Support
- REST API with WebSocket support
- TypeScript/JavaScript SDK
- JVM/Kotlin client
- React Native bindings
- Docker deployment ready
Use Cases
Portal is perfect for:
- SaaS Applications: Authenticate users and process subscriptions
- Content Creators: Monetize content with Lightning micropayments
- Online Services: Provide privacy-respecting authentication
- Event Ticketing: Issue and verify Cashu token-based tickets
- Membership Sites: Manage recurring memberships
- API Services: Verify JWT tokens issued by user wallets for API access
How It Works
- Deploy the Portal SDK Daemon: Run the REST API server using Docker
- Integrate the TypeScript SDK: Connect your application to Portal
- Authenticate Users: Generate authentication URLs for users to connect
- Process Payments: Request single or recurring payments
- Issue Tickets: Generate and send Cashu tokens as tickets to users
Architecture
Portal consists of several components:
- SDK Core: Rust-based core implementation handling Nostr protocol and Lightning payments
- REST API: WebSocket-based API server for language-agnostic integration
- TypeScript Client: High-level SDK for JavaScript/TypeScript applications
- Nostr Relays: Distributed network for message passing
- Lightning Network: Bitcoin Layer 2 for instant payments
Getting Started
Ready to integrate Portal into your application? Start with our Quick Start Guide or jump directly to:
- Deploying with Docker
- TypeScript SDK Setup
- Authentication Flow
- Static Tokens & Physical Auth
- Payment Processing
- Cashu Tokens
- Running Your Own Mint
Open Source
Portal is open source and available under the MIT License (except for the app library). Contributions are welcome!
Next Steps: Head over to the Quick Start Guide to deploy your first Portal instance.
What is Nostr?
Nostr (Notes and Other Stuff Transmitted by Relays) is a simple, open protocol that enables global, decentralized, and censorship-resistant social media.
Core Concepts
1. Identity
In Nostr, your identity is a cryptographic key pair:
- Private Key (nsec): Your secret key that you never share. It proves you are who you say you are.
- Public Key (npub): Your public identifier that you share with others. It's like your username, but cryptographically secure.
No email, no phone number, no centralized authority needed.
2. Relays
Relays are simple servers that store and forward messages (called "events"). Unlike traditional social media:
- Anyone can run a relay
- You can connect to multiple relays simultaneously
- Relays don't own your data
- If one relay goes down, you still have your messages on other relays
3. Events
Everything in Nostr is an "event" - a signed JSON message. Events include:
- Social media posts
- Direct messages
- Authentication requests
- Payment requests
- And more...
Why Nostr for Portal?
Portal uses Nostr because it provides:
- Decentralized Identity: Users control their own keys and identity
- No Central Server: Communication happens through distributed relays
- Censorship Resistance: No single point of control
- Privacy: Direct peer-to-peer messaging
- Interoperability: Standard protocol that works across applications
Nostr in Portal's Authentication Flow
When a user authenticates with Portal:
- Your application generates an authentication challenge
- The challenge is published to Nostr relays
- The user's wallet (like Alby, Mutiny, or others) picks up the challenge
- The user approves or denies the authentication
- The response is published back to Nostr
- Your application receives the response
All of this happens peer-to-peer through Nostr relays, with no central authentication server.
Learn More
Next: Learn about Lightning Network
What is Lightning Network?
The Lightning Network is a "Layer 2" payment protocol built on top of Bitcoin that enables fast, low-cost transactions.
Why Lightning?
Traditional Bitcoin transactions:
- Can take 10+ minutes to confirm
- Have transaction fees that can be high during busy times
- Are recorded on the blockchain forever
Lightning Network transactions:
- Are instant (sub-second)
- Have minimal fees (often less than 1 satoshi)
- Are private (not all details go on the blockchain)
- Enable micropayments (pay fractions of a cent)
How It Works (Simplified)
- Payment Channels: Two parties open a channel by creating a special Bitcoin transaction
- Off-Chain Transactions: They can then make unlimited instant transactions between each other
- Network of Channels: Payments can route through multiple channels to reach any destination
- Settlement: Channels can be closed at any time, settling the final balance on the Bitcoin blockchain
Lightning in Portal
Portal uses Lightning Network for:
Single Payments
One-time payments for purchases, tips, or services:
await client.requestSinglePayment(
userKey,
[],
{
amount: 1000, // 1 sat (1000 millisats)
currency: Currency.Millisats,
description: "Premium subscription"
},
(status) => {
if (status.status === 'paid') {
console.log('Payment received!');
}
}
);
Recurring Payments
Subscription-based payments with automatic billing:
await client.requestRecurringPayment(
userKey,
[],
{
amount: 10000, // 10 sats per month
currency: Currency.Millisats,
recurrence: {
calendar: "monthly",
first_payment_due: Timestamp.fromNow(86400),
max_payments: 12
},
expires_at: Timestamp.fromNow(3600)
}
);
Nostr Wallet Connect (NWC)
Portal uses Nostr Wallet Connect, a protocol that allows:
- Requesting payments through Nostr messages
- User approval through their Lightning wallet
- Real-time payment status updates
- Non-custodial payment flow (users maintain control of funds)
The user's Lightning wallet could be:
Payment Flow in Portal
- Payment Request: Your app requests a payment through Portal
- Nostr Message: Request is sent to the user via Nostr
- Wallet Notification: User's wallet shows the payment request
- User Approval: User approves or denies the payment
- Lightning Payment: Wallet sends payment via Lightning Network
- Confirmation: Your app receives real-time payment confirmation
All within seconds, with minimal fees.
Benefits for Your Business
- Instant Settlement: Receive payments immediately
- Global Reach: Accept payments from anyone, anywhere
- No Chargebacks: Bitcoin payments are final
- Low Fees: Typically < 1% (often much less)
- No Middlemen: Direct payment from customer to you
- Privacy: No personal information required
Learn More
Next: Start integrating Portal with the Quick Start Guide
Quick Start
Get Portal up and running in under 5 minutes!
Overview
This guide will help you:
- Deploy the Portal SDK Daemon using Docker
- Install the TypeScript SDK
- Create your first authentication flow
Prerequisites
- Docker installed
- Node.js 18+ and npm
- A Nostr private key (we'll show you how to generate one)
- (Optional) A Lightning wallet with Nostr Wallet Connect support
Step 1: Generate a Nostr Key
Your Portal instance needs a Nostr private key to operate. You can generate one using:
Option A: Using nostrtool.com (easiest)
- Visit nostrtool.com
- Click on "Key Generator"
- Copy your private key (nsec...)
- Important: Do this offline or in a private browser window for maximum security
Option B: Using a Nostr client
- Download Alby Extension
- Create an account
- Go to Settings ā Developer Settings ā Export Keys
- Copy your private key (nsec...)
Option C: Using a command-line tool
# Install nak (Nostr Army Knife)
npm install -g nak
# Generate a new key pair
nak key generate
Important: Keep your private key secure. Anyone with access to it can impersonate your Portal instance.
Step 2: Deploy with Docker
Run the Portal SDK Daemon:
docker run --rm --name portal-sdk-daemon -d \
-p 3000:3000 \
-e AUTH_TOKEN=your-secret-auth-token-change-this \
-e NOSTR_KEY=your-nostr-private-key-hex \
getportal/sdk-daemon:latest
Replace:
your-secret-auth-token-change-thiswith a strong random tokenyour-nostr-private-key-hexwith your Nostr private key in hex format
Verify it's running:
curl http://localhost:3000/health
# Should return: OK
Step 3: Install the TypeScript SDK
In your project directory:
npm install portal-sdk
Step 4: Your First Integration
Create a file portal-demo.js:
import { PortalSDK } from 'portal-sdk';
async function main() {
// Initialize the SDK
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
// Connect to the server
await client.connect();
console.log('Connected to Portal!');
// Authenticate with your token
await client.authenticate('your-secret-auth-token-change-this');
console.log('Authenticated!');
// Generate an authentication URL for a user
const url = await client.newKeyHandshakeUrl((mainKey) => {
console.log('User authenticated with key:', mainKey);
// Here you would typically:
// - Create a user account
// - Generate a session token
// - Store the user's public key
});
console.log('\nš Authentication URL generated!');
console.log('Share this URL with your user:');
console.log(url);
console.log('\nWaiting for user authentication...');
}
main().catch(console.error);
Run it:
node portal-demo.js
Step 5: Test Authentication
- The script will output an authentication URL
- Open the URL in a browser (or share it with a user)
- If you have Alby or another NWC-compatible wallet, it will ask you to approve the connection
- Once approved, your script will log the user's public key
Congratulations! š You've just authenticated a user without any passwords, email verification, or centralized auth service.
What's Next?
Now that you have Portal running, explore:
- Process Payments: Accept Lightning payments
- Issue Cashu Tokens: Create tickets and vouchers for users
- Run Your Own Mint: Deploy a custom Cashu mint for tickets
- JWT Tokens: Session management and API authentication
- Profile Management: Fetch user profiles from Nostr
- Production Deployment: Deploy Portal for production use
Common Issues
"Connection refused"
- Make sure Docker container is running:
docker ps - Check the port is correct (default: 3000)
"Authentication failed"
- Verify your AUTH_TOKEN matches between Docker and SDK
- Check Docker logs:
docker logs portal-sdk-daemon
"Invalid Nostr key"
- Ensure your key is in hex format (not nsec)
- Convert nsec to hex using:
nak decode nsec your-key-here
Example Projects
Check out complete examples:
Need Help? Check the FAQ or Troubleshooting Guide
Docker Deployment
Deploy the Portal SDK Daemon using Docker for easy setup and management.
Quick Deployment
Using Pre-built Image
The easiest way to run Portal is using the official Docker image:
docker run --rm --name portal-sdk-daemon -d \
-p 3000:3000 \
-e AUTH_TOKEN=your-secret-token \
-e NOSTR_KEY=your-nostr-private-key \
getportal/sdk-daemon:latest
With Docker Compose
Create a docker-compose.yml file:
version: '3.8'
services:
portal:
image: getportal/sdk-daemon:latest
container_name: portal-sdk-daemon
ports:
- "3000:3000"
environment:
- AUTH_TOKEN=${AUTH_TOKEN}
- NOSTR_KEY=${NOSTR_KEY}
- NWC_URL=${NWC_URL:-}
- NOSTR_RELAYS=${NOSTR_RELAYS:-}
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
Create a .env file:
AUTH_TOKEN=your-secret-token-here
NOSTR_KEY=your-nostr-private-key-hex
NWC_URL=nostr+walletconnect://...
NOSTR_RELAYS=wss://relay.damus.io,wss://relay.snort.social
Start the service:
docker-compose up -d
Environment Variables
Required Variables
| Variable | Description | Example |
|---|---|---|
AUTH_TOKEN | Secret token for API authentication | random-secret-token-12345 |
NOSTR_KEY | Nostr private key in hex format | 5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab7a |
Optional Variables
| Variable | Description | Default |
|---|---|---|
NWC_URL | Nostr Wallet Connect URL for processing payments | None |
NOSTR_SUBKEY_PROOF | Proof for Nostr subkey delegation | None |
NOSTR_RELAYS | Comma-separated list of relay URLs | Common public relays |
Configuration Examples
Development Setup
For local development with minimal configuration:
docker run --rm --name portal-dev \
-p 3000:3000 \
-e AUTH_TOKEN=dev-token \
-e NOSTR_KEY=$(cat ~/.nostr/key.hex) \
getportal/sdk-daemon:latest
Production Setup
For production with all features enabled:
docker run -d \
--name portal-production \
--restart unless-stopped \
-p 3000:3000 \
-e AUTH_TOKEN=$(openssl rand -hex 32) \
-e NOSTR_KEY=$(cat /secure/nostr-key.hex) \
-e NWC_URL="nostr+walletconnect://..." \
-e NOSTR_RELAYS="wss://relay.damus.io,wss://relay.snort.social,wss://nos.lol" \
--health-cmd="curl -f http://localhost:3000/health || exit 1" \
--health-interval=30s \
--health-timeout=10s \
--health-retries=3 \
getportal/sdk-daemon:latest
With Persistent Storage
If you need to persist data (like session information):
docker run -d \
--name portal \
-p 3000:3000 \
-v portal-data:/app/data \
-e AUTH_TOKEN=your-token \
-e NOSTR_KEY=your-key \
getportal/sdk-daemon:latest
Network Configuration
Exposing to External Networks
By default, Portal listens on all interfaces (0.0.0.0:3000). To expose it externally:
# Bind to specific host interface
docker run -d \
--name portal \
-p 192.168.1.100:3000:3000 \
-e AUTH_TOKEN=your-token \
-e NOSTR_KEY=your-key \
getportal/sdk-daemon:latest
Behind a Reverse Proxy
For production, use a reverse proxy like Nginx or Caddy:
Nginx configuration:
server {
listen 443 ssl http2;
server_name portal.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Caddy configuration:
portal.yourdomain.com {
reverse_proxy localhost:3000
}
Building Custom Images
Building from Source with Nix
Portal uses Nix for reproducible builds:
# Clone the repository
git clone https://github.com/PortalTechnologiesInc/lib.git
cd portal
# Build the Docker image
nix build .#rest-docker
# Load into Docker
docker load < result
Multi-Architecture Builds
To build for multiple architectures:
# On amd64 machine
nix build .#rest-docker
docker tag portal-rest:latest getportal/sdk-daemon:amd64
docker push getportal/sdk-daemon:amd64
# On arm64 machine
nix build .#rest-docker
docker tag portal-rest:latest getportal/sdk-daemon:arm64
docker push getportal/sdk-daemon:arm64
# Create and push manifest
docker manifest create getportal/sdk-daemon:latest \
--amend getportal/sdk-daemon:amd64 \
--amend getportal/sdk-daemon:arm64
docker manifest push getportal/sdk-daemon:latest
Container Management
Viewing Logs
# Follow logs in real-time
docker logs -f portal-sdk-daemon
# View last 100 lines
docker logs --tail 100 portal-sdk-daemon
# View logs with timestamps
docker logs -t portal-sdk-daemon
Monitoring Health
# Check container status
docker ps -f name=portal-sdk-daemon
# Check health status
docker inspect --format='{{.State.Health.Status}}' portal-sdk-daemon
# Test health endpoint directly
curl http://localhost:3000/health
Restarting the Service
# Restart container
docker restart portal-sdk-daemon
# Stop container
docker stop portal-sdk-daemon
# Remove container
docker rm portal-sdk-daemon
Updating to Latest Version
# Pull latest image
docker pull getportal/sdk-daemon:latest
# Stop and remove old container
docker stop portal-sdk-daemon
docker rm portal-sdk-daemon
# Start new container
docker run -d \
--name portal-sdk-daemon \
-p 3000:3000 \
-e AUTH_TOKEN=your-token \
-e NOSTR_KEY=your-key \
getportal/sdk-daemon:latest
Security Considerations
- Never commit secrets: Don't include
AUTH_TOKENorNOSTR_KEYin version control - Use strong tokens: Generate cryptographically secure random tokens
- Restrict network access: Use firewalls to limit who can connect
- Enable HTTPS: Use a reverse proxy with SSL/TLS
- Regular updates: Keep the Docker image updated
- Monitor logs: Watch for suspicious activity
Troubleshooting
Container won't start
# Check logs for errors
docker logs portal-sdk-daemon
# Verify environment variables
docker inspect portal-sdk-daemon | grep -A 20 Env
Health check failing
# Test health endpoint
curl http://localhost:3000/health
# Check if port is accessible
netstat -tlnp | grep 3000
# Verify container is running
docker ps -a
Permission issues
# Run with specific user
docker run -d \
--user 1000:1000 \
--name portal \
-p 3000:3000 \
-e AUTH_TOKEN=token \
-e NOSTR_KEY=key \
getportal/sdk-daemon:latest
Next Steps:
Environment Variables
Configure your Portal SDK Daemon with environment variables.
Required Variables
AUTH_TOKEN
Description: Authentication token for API access. This token must be provided by clients when connecting to the WebSocket API.
Type: String
Security: Generate a cryptographically secure random token. Never commit this to version control.
Example:
# Generate a secure token
openssl rand -hex 32
# Or use a password generator
pwgen -s 64 1
Usage:
AUTH_TOKEN=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
NOSTR_KEY
Description: Your Portal instance's Nostr private key in hexadecimal format. This key is used to sign messages and authenticate your service on the Nostr network.
Type: Hexadecimal string (64 characters)
Security: Keep this key absolutely secret. Anyone with access to it can impersonate your Portal instance.
Format: Hex format (not nsec format)
Converting from nsec:
# If you have an nsec key, convert it to hex:
nak decode nsec1your-key-here
Usage:
NOSTR_KEY=5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab7a
Optional Variables
NWC_URL
Description: Nostr Wallet Connect URL for processing Lightning Network payments. This allows your Portal instance to request and receive payments on behalf of your service.
Type: String (nostr+walletconnect:// URL)
Required for: Payment processing (single and recurring payments)
How to get:
- Use a Lightning wallet that supports NWC (Alby, Mutiny, etc.)
- Navigate to wallet settings
- Find "Nostr Wallet Connect" or "Wallet Connect String"
- Copy the connection URL
Example:
NWC_URL=nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss://relay.damus.io&secret=abcdef123456
Without NWC: Portal can still handle authentication and generate payment requests, but users will need to pay invoices manually.
NOSTR_SUBKEY_PROOF
Description: Proof for Nostr subkey delegation. This is used when your Portal instance operates as a subkey delegated from a main key.
Type: String (delegation proof)
Use case: Advanced scenarios where you want to use a delegated subkey instead of a main key.
Example:
NOSTR_SUBKEY_PROOF=delegation-proof-string-here
NOSTR_RELAYS
Description: Comma-separated list of Nostr relay URLs to connect to. Relays are used to publish and receive messages on the Nostr network.
Type: Comma-separated string
Default: If not specified, Portal uses a default set of popular public relays.
Recommended relays:
wss://relay.damus.io- Popular, well-maintainedwss://relay.snort.social- Fast and reliablewss://nos.lol- Good for paymentswss://relay.nostr.band- Large relay networkwss://nostr.wine- Paid relay (more reliable)
Example:
NOSTR_RELAYS=wss://relay.damus.io,wss://relay.snort.social,wss://nos.lol
Considerations:
- More relays = better redundancy but more bandwidth
- Include at least 3-5 relays for reliability
- Use relays that are geographically close to your users
- Consider using paid relays for production
Configuration Examples
Minimal Development Setup
Bare minimum for local development:
AUTH_TOKEN=dev-token-change-in-production
NOSTR_KEY=5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab7a
Full Production Setup
Complete configuration for production deployment:
# Required
AUTH_TOKEN=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6
NOSTR_KEY=5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab7a
# Payment processing
NWC_URL=nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss://relay.damus.io&secret=abcdef123456
# Network configuration
NOSTR_RELAYS=wss://relay.damus.io,wss://relay.snort.social,wss://nos.lol,wss://relay.nostr.band,wss://nostr.wine
Using Environment Files
.env file (for docker-compose)
Create a .env file in your project directory:
# Portal Configuration
AUTH_TOKEN=your-secret-token
NOSTR_KEY=your-nostr-key-hex
NWC_URL=nostr+walletconnect://your-nwc-url
NOSTR_RELAYS=wss://relay.damus.io,wss://relay.snort.social
Important: Add .env to your .gitignore:
echo ".env" >> .gitignore
Using with Docker
# Load from .env file
docker run --env-file .env -p 3000:3000 getportal/sdk-daemon:latest
# Or pass variables directly
docker run \
-e AUTH_TOKEN=$AUTH_TOKEN \
-e NOSTR_KEY=$NOSTR_KEY \
-e NWC_URL=$NWC_URL \
-p 3000:3000 \
getportal/sdk-daemon:latest
Using with Docker Compose
version: '3.8'
services:
portal:
image: getportal/sdk-daemon:latest
env_file:
- .env
ports:
- "3000:3000"
Security Best Practices
1. Generate Strong Tokens
# Use openssl
openssl rand -base64 32
# Or use a dedicated tool
pwgen -s 64 1
# On Linux/macOS
head -c 32 /dev/urandom | base64
2. Secure Storage
DO:
- Store secrets in environment variables
- Use secret management systems (AWS Secrets Manager, HashiCorp Vault)
- Encrypt secrets at rest
- Rotate tokens regularly
DON'T:
- Commit secrets to version control
- Include secrets in Docker images
- Share secrets in plain text
- Hardcode secrets in application code
3. Access Control
# Set proper file permissions for .env files
chmod 600 .env
# Verify permissions
ls -l .env
# Should show: -rw------- (only owner can read/write)
4. Secret Rotation
Regularly rotate your secrets:
# Generate new AUTH_TOKEN
NEW_TOKEN=$(openssl rand -hex 32)
# Update in .env
sed -i "s/AUTH_TOKEN=.*/AUTH_TOKEN=$NEW_TOKEN/" .env
# Restart Portal
docker-compose restart
Validation
Checking Current Configuration
# View environment variables in running container
docker exec portal-sdk-daemon env | grep -E 'AUTH_TOKEN|NOSTR_KEY|NWC_URL|NOSTR_RELAYS'
# Note: This will show your secrets! Only use for debugging
Testing Configuration
# Test health endpoint
curl http://localhost:3000/health
# Test WebSocket connection
wscat -c ws://localhost:3000/ws
# Send auth command
{"id":"test","cmd":"Auth","params":{"token":"your-auth-token"}}
Troubleshooting
"Authentication failed"
Cause: AUTH_TOKEN mismatch between server and client
Solution:
# Verify token in container
docker exec portal-sdk-daemon env | grep AUTH_TOKEN
# Check your SDK code uses the same token
"Invalid NOSTR_KEY format"
Cause: Key is not in hex format or is invalid
Solution:
# Key should be 64 hex characters
echo $NOSTR_KEY | wc -c
# Should output: 65 (64 chars + newline)
# Verify it's valid hex
echo $NOSTR_KEY | grep -E '^[0-9a-f]{64}$'
"Cannot connect to relays"
Cause: Invalid relay URLs or network issues
Solution:
# Test relay connectivity
wscat -c wss://relay.damus.io
# Verify relay URLs are correct (must start with wss://)
echo $NOSTR_RELAYS | tr ',' '\n'
Next Steps:
Building from Source
Build Portal from source code for development or custom deployments.
Prerequisites
Required Tools
- Rust Toolchain (1.70+)
# Install Rust using rustup
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Verify installation
rustc --version
cargo --version
- Git
# Verify git is installed
git --version
Optional Tools
For building with Nix (recommended for reproducible builds):
# Install Nix
curl -L https://nixos.org/nix/install | sh
# Enable flakes (if not already enabled)
mkdir -p ~/.config/nix
echo "experimental-features = nix-command flakes" >> ~/.config/nix/nix.conf
Clone the Repository
git clone https://github.com/PortalTechnologiesInc/lib.git
cd portal
Building with Cargo
Build the REST API
# Build in debug mode (faster compilation, slower runtime)
cargo build --package rest
# Build in release mode (optimized)
cargo build --package rest --release
# Run the binary
./target/release/rest
Build All Components
# Build everything
cargo build --release
# Build specific components
cargo build --package app --release
cargo build --package cli --release
cargo build --package rates --release
Build the TypeScript SDK
cd rest/clients/ts
# Install dependencies
npm install
# Build the TypeScript SDK
npm run build
# Run tests
npm test
# Build for production
npm run build:production
Building with Nix
Nix provides reproducible, deterministic builds:
Build the REST API
# Build the REST API server
nix build .#rest
# Run it
./result/bin/rest
Build Docker Image
# Build Docker image for your architecture
nix build .#rest-docker
# Load into Docker
docker load < result
# Tag and run
docker tag portal-rest:latest portal:local
docker run -p 3000:3000 portal:local
Build for Different Architectures
# Build for x86_64 Linux
nix build .#rest --system x86_64-linux
# Build for ARM64 Linux
nix build .#rest --system aarch64-linux
# Build for macOS
nix build .#rest --system aarch64-darwin
Development Setup
Set Up Development Environment
# Enter Nix development shell (if using Nix)
nix develop
# Or set up manually with Cargo
cargo install cargo-watch
cargo install cargo-edit
Run in Development Mode
# Run REST API with auto-reload
cargo watch -x 'run --package rest'
# Run with environment variables
AUTH_TOKEN=dev-token \
NOSTR_KEY=your-key-hex \
cargo run --package rest
# Or use the env.example
cp rest/env.example rest/.env
# Edit .env with your values
source rest/.env
cargo run --package rest
Run Tests
# Run all tests
cargo test
# Run tests for specific package
cargo test --package rest
cargo test --package app
# Run with output
cargo test -- --nocapture
# Run specific test
cargo test test_name
Building TypeScript Client
Development Build
cd rest/clients/ts
# Install dependencies
npm install
# Build in watch mode
npm run build -- --watch
# Run example
npm run example
Production Build
# Build optimized version
npm run build
# Create package
npm pack
# Publish to npm (requires authentication)
npm publish
Building the CLI
# Build the CLI tool
cargo build --package cli --release
# Run it
./target/release/cli --help
# Install globally
cargo install --path cli
Cross-Compilation
Linux ā Windows
# Add Windows target
rustup target add x86_64-pc-windows-gnu
# Install mingw-w64
# On Ubuntu/Debian:
sudo apt-get install mingw-w64
# Build
cargo build --package rest --target x86_64-pc-windows-gnu --release
Linux ā macOS
Cross-compiling to macOS requires osxcross. Using Nix is easier:
nix build .#rest --system x86_64-darwin
nix build .#rest --system aarch64-darwin
Optimizations
Size Optimization
Edit Cargo.toml:
[profile.release]
opt-level = "z" # Optimize for size
lto = true # Enable Link Time Optimization
codegen-units = 1 # Better optimization
strip = true # Strip symbols
Build:
cargo build --package rest --release
Performance Optimization
[profile.release]
opt-level = 3 # Maximum optimization
lto = "fat" # Full LTO
codegen-units = 1
Development Optimization
[profile.dev]
opt-level = 1 # Some optimization for faster dev builds
Platform-Specific Builds
Linux
# Build with system libraries
cargo build --package rest --release
# Build static binary (Linux only)
cargo build --package rest --release --target x86_64-unknown-linux-musl
macOS
# Build for current architecture
cargo build --package rest --release
# Build universal binary (both Intel and Apple Silicon)
cargo build --package rest --release --target x86_64-apple-darwin
cargo build --package rest --release --target aarch64-apple-darwin
# Create universal binary
lipo -create \
target/x86_64-apple-darwin/release/rest \
target/aarch64-apple-darwin/release/rest \
-output target/release/rest-universal
Windows
# Build for Windows
cargo build --package rest --release --target x86_64-pc-windows-msvc
Creating Releases
Binary Releases
# Build all release binaries
cargo build --release --workspace
# Create release directory
mkdir -p releases/portal-v1.0.0
# Copy binaries
cp target/release/rest releases/portal-v1.0.0/
cp target/release/cli releases/portal-v1.0.0/
# Create tarball
tar -czf portal-v1.0.0-linux-x86_64.tar.gz -C releases portal-v1.0.0/
Docker Release
# Build Docker image
nix build .#rest-docker
docker load < result
# Tag for release
docker tag portal-rest:latest getportal/sdk-daemon:v1.0.0
docker tag portal-rest:latest getportal/sdk-daemon:latest
# Push to registry
docker push getportal/sdk-daemon:v1.0.0
docker push getportal/sdk-daemon:latest
Troubleshooting
Compilation Errors
"cannot find -lssl"
# Install OpenSSL development libraries
# Ubuntu/Debian:
sudo apt-get install libssl-dev pkg-config
# macOS:
brew install openssl pkg-config
# Set PKG_CONFIG_PATH if needed
export PKG_CONFIG_PATH=/usr/local/opt/openssl/lib/pkgconfig
"linker 'cc' not found"
# Install build essentials
# Ubuntu/Debian:
sudo apt-get install build-essential
# macOS:
xcode-select --install
Slow Compilation
# Use faster linker (Linux)
sudo apt-get install lld
echo '[build]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]' >> ~/.cargo/config.toml
# Or use mold (even faster)
cargo install mold
Out of Memory
# Limit parallel jobs
cargo build --jobs 2 --release
# Or set in config
echo '[build]
jobs = 2' >> ~/.cargo/config.toml
Nix Build Issues
# Clean build cache
nix-collect-garbage
# Update flake inputs
nix flake update
# Build with verbose output
nix build -L .#rest
Development Workflow
Recommended Workflow
- Make changes to source code
- Run tests:
cargo test - Check code:
cargo clippy - Format code:
cargo fmt - Build:
cargo build --release - Test locally: Run the binary
- Commit changes:
git commit
Pre-commit Hooks
Create .git/hooks/pre-commit:
#!/bin/bash
set -e
echo "Running cargo fmt..."
cargo fmt --all -- --check
echo "Running cargo clippy..."
cargo clippy --all-targets --all-features -- -D warnings
echo "Running tests..."
cargo test --all
echo "All checks passed!"
Make it executable:
chmod +x .git/hooks/pre-commit
Next Steps:
TypeScript SDK Installation
Install and set up the Portal TypeScript SDK in your project.
Installation
Using npm
npm install portal-sdk
Using yarn
yarn add portal-sdk
Using pnpm
pnpm add portal-sdk
Requirements
- Node.js: 18.x or higher
- TypeScript (optional): 4.5 or higher
- Portal SDK Daemon: Running instance (see Docker Deployment)
Verify Installation
Create a test file to verify the installation:
import { PortalSDK } from 'portal-sdk';
console.log('Portal SDK imported successfully!');
Run it:
node test.js
TypeScript Support
The SDK includes full TypeScript definitions. No additional @types packages are needed.
tsconfig.json Setup
Recommended TypeScript configuration:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true
}
}
Import Options
ES Modules
import { PortalSDK, Currency, Timestamp } from 'portal-sdk';
CommonJS
const { PortalSDK, Currency, Timestamp } = require('portal-sdk');
Import Individual Types
import {
PortalSDK,
Currency,
Timestamp,
Profile,
AuthResponseData,
InvoiceStatus,
RecurringPaymentRequestContent,
SinglePaymentRequestContent
} from 'portal-sdk';
Browser Support
The SDK works in both Node.js and browser environments.
Browser Setup
<!DOCTYPE html>
<html>
<head>
<title>Portal SDK Example</title>
</head>
<body>
<script type="module">
import { PortalSDK } from './node_modules/portal-sdk/dist/index.js';
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
// Your code here
</script>
</body>
</html>
Webpack Configuration
If using Webpack, you may need to configure WebSocket:
// webpack.config.js
module.exports = {
resolve: {
fallback: {
"ws": false
}
}
};
Browser Bundlers
The SDK uses isomorphic-ws which automatically handles WebSocket in both Node.js and browser environments. Most modern bundlers (Vite, Rollup, esbuild) will handle this automatically.
Framework Integration
React
import React, { useEffect, useState } from 'react';
import { PortalSDK } from 'portal-sdk';
function App() {
const [client, setClient] = useState<PortalSDK | null>(null);
useEffect(() => {
const portalClient = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
portalClient.connect().then(() => {
portalClient.authenticate('your-auth-token').then(() => {
setClient(portalClient);
});
});
return () => {
portalClient.disconnect();
};
}, []);
return (
<div>
{client ? 'Connected to Portal' : 'Connecting...'}
</div>
);
}
Next.js
// lib/portal.ts
import { PortalSDK } from 'portal-sdk';
let client: PortalSDK | null = null;
export function getPortalClient() {
if (!client) {
client = new PortalSDK({
serverUrl: process.env.NEXT_PUBLIC_PORTAL_WS_URL || 'ws://localhost:3000/ws'
});
}
return client;
}
Use in API route:
// pages/api/auth.ts
import { getPortalClient } from '@/lib/portal';
export default async function handler(req, res) {
const client = getPortalClient();
await client.connect();
await client.authenticate(process.env.PORTAL_AUTH_TOKEN);
// Use client...
res.status(200).json({ success: true });
}
Vue.js
// plugins/portal.ts
import { PortalSDK } from 'portal-sdk';
export default defineNuxtPlugin(() => {
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
return {
provide: {
portal: client
}
};
});
Express.js
import express from 'express';
import { PortalSDK } from 'portal-sdk';
const app = express();
// Initialize Portal client
const portalClient = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
// Connect on server start
portalClient.connect().then(() => {
return portalClient.authenticate(process.env.PORTAL_AUTH_TOKEN);
}).then(() => {
console.log('Portal SDK connected');
});
// Use in routes
app.post('/api/authenticate', async (req, res) => {
const url = await portalClient.newKeyHandshakeUrl((mainKey) => {
console.log('User authenticated:', mainKey);
// Create user session...
});
res.json({ authUrl: url });
});
app.listen(3001, () => {
console.log('Server running on port 3001');
});
Environment Variables
Store your Portal configuration in environment variables:
# .env
PORTAL_WS_URL=ws://localhost:3000/ws
PORTAL_AUTH_TOKEN=your-secret-auth-token
Access in your code:
import { PortalSDK } from 'portal-sdk';
const client = new PortalSDK({
serverUrl: process.env.PORTAL_WS_URL || 'ws://localhost:3000/ws'
});
await client.connect();
await client.authenticate(process.env.PORTAL_AUTH_TOKEN || '');
Package Information
Exports
The package exports the following:
PortalSDK- Main client classCurrency- Currency enumTimestamp- Timestamp utility class- All TypeScript types and interfaces
Bundle Size
- Minified: ~50KB
- Minified + Gzipped: ~15KB
Dependencies
The SDK has minimal dependencies:
ws- WebSocket client for Node.jsisomorphic-ws- Universal WebSocket wrapper
Troubleshooting
"Cannot find module 'portal-sdk'"
# Clear node_modules and reinstall
rm -rf node_modules package-lock.json
npm install
TypeScript Errors
# Ensure TypeScript is installed
npm install --save-dev typescript
# Check your tsconfig.json includes the right settings
WebSocket Connection Issues
# Verify Portal daemon is running
curl http://localhost:3000/health
# Check WebSocket URL is correct (ws:// not wss:// for local dev)
Module Resolution Errors
If using ES modules, ensure your package.json has:
{
"type": "module"
}
Or use .mjs file extension:
mv app.js app.mjs
Next Steps
- Basic Usage - Learn how to use the SDK
- Configuration - Configure the SDK
- Authentication Guide - Implement authentication
Ready to start coding? Head to Basic Usage
Basic Usage
Learn the fundamentals of using the Portal TypeScript SDK.
Quick Example
Here's a complete example showing the basic workflow:
import { PortalSDK } from 'portal-sdk';
async function main() {
// 1. Create client
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
// 2. Connect
await client.connect();
console.log('ā
Connected');
// 3. Authenticate
await client.authenticate('your-auth-token');
console.log('ā
Authenticated');
// 4. Generate auth URL for user
const authUrl = await client.newKeyHandshakeUrl((mainKey) => {
console.log('ā
User authenticated:', mainKey);
});
console.log('Share this URL:', authUrl);
// Keep running...
await new Promise(() => {});
}
main().catch(console.error);
Core Concepts
1. Client Initialization
Create a Portal client instance:
import { PortalSDK } from 'portal-sdk';
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws',
connectTimeout: 10000 // Optional: timeout in milliseconds
});
Configuration Options:
serverUrl(required): WebSocket URL of your Portal daemonconnectTimeout(optional): Connection timeout in ms (default: 10000)
2. Connection Management
Connect
try {
await client.connect();
console.log('Connected successfully');
} catch (error) {
console.error('Connection failed:', error);
}
Disconnect
client.disconnect();
console.log('Disconnected');
Connection Events
client.on({
onConnected: () => {
console.log('Connection established');
},
onDisconnected: () => {
console.log('Connection closed');
},
onError: (error) => {
console.error('Connection error:', error);
}
});
3. Authentication
Authenticate with your Portal daemon:
try {
await client.authenticate('your-auth-token');
console.log('Authenticated with Portal');
} catch (error) {
console.error('Authentication failed:', error);
}
Important: You must authenticate before calling any other API methods.
Basic Workflows
User Authentication Flow
import { PortalSDK } from 'portal-sdk';
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
await client.connect();
await client.authenticate(process.env.AUTH_TOKEN);
// Generate authentication URL
const authUrl = await client.newKeyHandshakeUrl(async (userPubkey, preferredRelays) => {
console.log('User pubkey:', userPubkey);
console.log('User relays:', preferredRelays);
// Authenticate the user's key
const authResponse = await client.authenticateKey(userPubkey);
if (authResponse.status.status === 'approved') {
console.log('User approved authentication!');
// Create session, store user info, etc.
} else {
console.log('User declined authentication');
}
});
console.log('Send this to user:', authUrl);
Request a Single Payment
import { PortalSDK, Currency } from 'portal-sdk';
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
await client.connect();
await client.authenticate(process.env.AUTH_TOKEN);
const userPubkey = 'user-public-key-hex';
await client.requestSinglePayment(
userPubkey,
[], // subkeys (optional)
{
amount: 5000, // 5 sats (in millisats)
currency: Currency.Millisats,
description: 'Premium subscription'
},
(status) => {
console.log('Payment status:', status.status);
if (status.status === 'paid') {
console.log('Payment received! Preimage:', status.preimage);
// Grant access to premium features
} else if (status.status === 'user_rejected') {
console.log('User rejected payment');
} else if (status.status === 'timeout') {
console.log('Payment timed out');
}
}
);
Fetch User Profile
const userPubkey = 'user-public-key-hex';
const profile = await client.fetchProfile(userPubkey);
if (profile) {
console.log('Name:', profile.name);
console.log('Display name:', profile.display_name);
console.log('Picture:', profile.picture);
console.log('About:', profile.about);
console.log('NIP-05:', profile.nip05);
} else {
console.log('No profile found');
}
Working with Types
Timestamps
import { Timestamp } from 'portal-sdk';
// Create from specific date
const specificTime = Timestamp.fromDate(new Date('2024-12-31'));
// Create from now + seconds
const oneHourFromNow = Timestamp.fromNow(3600); // 1 hour
const oneDayFromNow = Timestamp.fromNow(86400); // 24 hours
// Use in payment requests
const paymentRequest = {
amount: 1000,
currency: Currency.Millisats,
expires_at: Timestamp.fromNow(3600)
};
Currency
import { Currency } from 'portal-sdk';
// Currently only Millisats is supported
const amount = 1000; // 1 sat = 1000 millisats
const currency = Currency.Millisats;
Profiles
import { Profile } from 'portal-sdk';
const profile: Profile = {
id: 'unique-id',
pubkey: 'user-public-key',
name: 'johndoe',
display_name: 'John Doe',
picture: 'https://example.com/avatar.jpg',
about: 'Software developer',
nip05: 'john@example.com'
};
// Update your service profile
await client.setProfile(profile);
Error Handling
Try-Catch Pattern
try {
await client.connect();
await client.authenticate('token');
const url = await client.newKeyHandshakeUrl((key) => {
console.log('User key:', key);
});
} catch (error) {
if (error.message.includes('timeout')) {
console.error('Connection timed out');
} else if (error.message.includes('Authentication failed')) {
console.error('Invalid auth token');
} else {
console.error('Unknown error:', error);
}
}
Graceful Degradation
async function connectWithRetry(maxAttempts = 3) {
for (let i = 0; i < maxAttempts; i++) {
try {
await client.connect();
return true;
} catch (error) {
console.log(`Attempt ${i + 1} failed, retrying...`);
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return false;
}
const connected = await connectWithRetry();
if (!connected) {
console.error('Failed to connect after retries');
}
Best Practices
1. Reuse Client Instance
// ā
Good - Single instance
const client = new PortalSDK({ serverUrl: 'ws://localhost:3000/ws' });
await client.connect();
await client.authenticate('token');
// Use client for multiple operations
const url1 = await client.newKeyHandshakeUrl(handler1);
const url2 = await client.newKeyHandshakeUrl(handler2);
// ā Bad - Multiple instances
const client1 = new PortalSDK({ serverUrl: 'ws://localhost:3000/ws' });
await client1.connect();
const client2 = new PortalSDK({ serverUrl: 'ws://localhost:3000/ws' });
await client2.connect();
2. Handle Cleanup
// Server shutdown
process.on('SIGTERM', () => {
client.disconnect();
process.exit(0);
});
// Unhandled errors
process.on('unhandledRejection', (error) => {
console.error('Unhandled rejection:', error);
client.disconnect();
process.exit(1);
});
3. Use Environment Variables
// ā
Good
const client = new PortalSDK({
serverUrl: process.env.PORTAL_WS_URL || 'ws://localhost:3000/ws'
});
await client.authenticate(process.env.PORTAL_AUTH_TOKEN);
// ā Bad - Hardcoded secrets
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
await client.authenticate('my-secret-token');
4. Validate User Input
async function authenticateUser(pubkey: string) {
// Validate pubkey format
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
throw new Error('Invalid pubkey format');
}
return await client.authenticateKey(pubkey);
}
5. Log Important Events
const client = new PortalSDK({
serverUrl: process.env.PORTAL_WS_URL
});
client.on({
onConnected: () => {
console.log('[Portal] Connected');
},
onDisconnected: () => {
console.log('[Portal] Disconnected');
},
onError: (error) => {
console.error('[Portal] Error:', error);
}
});
Complete Example
Here's a complete example integrating authentication and payments:
import { PortalSDK, Currency, Timestamp } from 'portal-sdk';
class PortalService {
private client: PortalSDK;
constructor(wsUrl: string, authToken: string) {
this.client = new PortalSDK({ serverUrl: wsUrl });
this.init(authToken);
}
private async init(authToken: string) {
await this.client.connect();
await this.client.authenticate(authToken);
this.client.on({
onDisconnected: () => {
console.log('Portal disconnected, attempting reconnect...');
this.init(authToken);
},
onError: (error) => {
console.error('Portal error:', error);
}
});
}
async createAuthUrl(onAuth: (pubkey: string) => void): Promise<string> {
return await this.client.newKeyHandshakeUrl(async (pubkey) => {
const authResult = await this.client.authenticateKey(pubkey);
if (authResult.status.status === 'approved') {
onAuth(pubkey);
}
});
}
async requestPayment(userPubkey: string, amount: number, description: string): Promise<boolean> {
return new Promise((resolve) => {
this.client.requestSinglePayment(
userPubkey,
[],
{
amount,
currency: Currency.Millisats,
description
},
(status) => {
if (status.status === 'paid') {
resolve(true);
} else if (status.status === 'user_rejected' || status.status === 'timeout') {
resolve(false);
}
}
);
});
}
async getUserProfile(pubkey: string) {
return await this.client.fetchProfile(pubkey);
}
disconnect() {
this.client.disconnect();
}
}
// Usage
const portal = new PortalService(
process.env.PORTAL_WS_URL!,
process.env.PORTAL_AUTH_TOKEN!
);
const authUrl = await portal.createAuthUrl((pubkey) => {
console.log('User authenticated:', pubkey);
});
console.log('Auth URL:', authUrl);
Next Steps:
- Configuration - Advanced configuration options
- Authentication Guide - Deep dive into authentication
- Payment Processing - Learn about payments
SDK Configuration
Configure the Portal TypeScript SDK for your specific needs.
Basic Configuration
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws',
connectTimeout: 10000
});
Configuration Options
serverUrl (required)
The WebSocket URL of your Portal daemon.
// Local development
serverUrl: 'ws://localhost:3000/ws'
// Production
serverUrl: 'wss://portal.yourdomain.com/ws'
connectTimeout (optional)
Connection timeout in milliseconds. Default: 10000 (10 seconds)
connectTimeout: 5000 // 5 seconds
Environment-Based Configuration
const config = {
serverUrl: process.env.PORTAL_WS_URL || 'ws://localhost:3000/ws',
connectTimeout: parseInt(process.env.PORTAL_TIMEOUT || '10000')
};
const client = new PortalSDK(config);
Event Configuration
Set up event listeners during initialization:
const client = new PortalSDK({ serverUrl: 'ws://localhost:3000/ws' });
client.on({
onConnected: () => console.log('Connected'),
onDisconnected: () => console.log('Disconnected'),
onError: (error) => console.error('Error:', error)
});
await client.connect();
Next: Error Handling
Error Handling
Handle errors gracefully in your Portal integration.
Common Error Types
Connection Errors
try {
await client.connect();
} catch (error) {
if (error.message.includes('timeout')) {
console.error('Connection timeout - is Portal daemon running?');
} else if (error.message.includes('ECONNREFUSED')) {
console.error('Connection refused - check serverUrl');
} else {
console.error('Connection error:', error);
}
}
Authentication Errors
try {
await client.authenticate('token');
} catch (error) {
if (error.message.includes('Authentication failed')) {
console.error('Invalid auth token');
} else {
console.error('Auth error:', error);
}
}
Payment Errors
client.requestSinglePayment(userPubkey, [], request, (status) => {
if (status.status === 'error') {
console.error('Payment error:', status.reason);
} else if (status.status === 'user_failed') {
console.error('Payment failed:', status.reason);
// Common reasons: insufficient funds, routing failure
}
});
Error Recovery
Connection Retry
async function connectWithRetry(maxAttempts = 3, delayMs = 1000) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await client.connect();
console.log('Connected successfully');
return true;
} catch (error) {
console.log(`Connection attempt ${attempt} failed`);
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
}
console.error('Failed to connect after retries');
return false;
}
Automatic Reconnection
client.on({
onDisconnected: () => {
console.log('Disconnected, attempting reconnect...');
setTimeout(async () => {
try {
await client.connect();
await client.authenticate(process.env.AUTH_TOKEN);
console.log('Reconnected successfully');
} catch (error) {
console.error('Reconnection failed:', error);
}
}, 5000);
}
});
Best Practices
- Always use try-catch for async operations
- Check status codes in callbacks
- Implement retry logic for critical operations
- Log errors with context
- Show user-friendly messages to end users
Next: Authentication Guide
Authentication Flow
Implement secure, passwordless authentication using Nostr and Portal.
Overview
Portal's authentication is based on Nostr's cryptographic key pairs. Instead of usernames and passwords, users prove their identity by signing challenges with their private keys.
How It Works
- Generate Auth URL: Your app creates an authentication URL
- User Opens URL: User clicks the link (opens in their Nostr wallet)
- Wallet Prompts: Wallet asks user to approve the authentication
- Key Handshake: Wallet sends user's public key and preferred relays
- Challenge-Response: Your app sends a challenge, user signs it
- Verification: You verify the signature and authenticate the user
Basic Implementation
Step 1: Generate Authentication URL
import { PortalSDK } from 'portal-sdk';
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
await client.connect();
await client.authenticate(process.env.AUTH_TOKEN);
const authUrl = await client.newKeyHandshakeUrl((mainKey, preferredRelays) => {
console.log('User public key:', mainKey);
console.log('User relays:', preferredRelays);
// Store this information
// Continue with authentication challenge...
});
console.log('Share this URL with user:', authUrl);
// Example: nostr:nprofile1...
Step 2: Present URL to User
The URL can be shared in multiple ways:
QR Code:
import QRCode from 'qrcode';
const authUrl = await client.newKeyHandshakeUrl(handleAuth);
// Generate QR code
const qrCodeDataUrl = await QRCode.toDataURL(authUrl);
// Display in HTML
// <img src="${qrCodeDataUrl}" alt="Scan to authenticate" />
Direct Link:
<a href="${authUrl}">Click to authenticate with your Nostr wallet</a>
Deep Link (Mobile):
// Opens directly in compatible wallets
window.location.href = authUrl;
Step 3: Handle Key Handshake
const authUrl = await client.newKeyHandshakeUrl(async (mainKey, preferredRelays) => {
console.log('Received key handshake from:', mainKey);
// Check if user exists in your database
const user = await findUserByPubkey(mainKey);
if (!user) {
console.log('New user, creating account...');
await createUser(mainKey, preferredRelays);
}
// Proceed with authentication challenge
await authenticateUser(mainKey);
});
Step 4: Authenticate the Key
async function authenticateUser(mainKey: string) {
try {
const authResponse = await client.authenticateKey(mainKey, []);
if (authResponse.status.status === 'approved') {
console.log('ā
User approved authentication!');
console.log('Challenge:', authResponse.challenge);
console.log('User key:', authResponse.user_key);
// Get session token from auth response (issued by user's wallet)
const sessionToken = authResponse.status.session_token;
// Store session
await storeSession(mainKey, sessionToken);
return sessionToken;
} else if (authResponse.status.status === 'declined') {
console.log('ā User declined authentication');
console.log('Reason:', authResponse.status.reason);
return null;
}
} catch (error) {
console.error('Authentication error:', error);
return null;
}
}
Complete Authentication Example
Here's a complete Express.js example:
import express from 'express';
import { PortalSDK } from 'portal-sdk';
import session from 'express-session';
const app = express();
// Session storage
const sessions = new Map<string, { pubkey: string, token: string }>();
// Initialize Portal
const portalClient = new PortalSDK({
serverUrl: process.env.PORTAL_WS_URL!
});
portalClient.connect().then(() => {
return portalClient.authenticate(process.env.PORTAL_AUTH_TOKEN!);
});
// Endpoint: Generate authentication URL
app.get('/api/auth/start', async (req, res) => {
try {
const authUrl = await portalClient.newKeyHandshakeUrl(
async (mainKey, preferredRelays) => {
console.log('Key handshake from:', mainKey);
// Authenticate the user
const authResponse = await portalClient.authenticateKey(mainKey);
if (authResponse.status.status === 'approved') {
// Get session token from auth response (issued by user's wallet)
const sessionToken = authResponse.status.session_token!;
// Store session
sessions.set(sessionToken, {
pubkey: mainKey,
token: sessionToken
});
console.log('User authenticated:', mainKey);
// In a real app, you might want to notify the frontend
// via WebSocket or have them poll for status
}
}
);
res.json({ authUrl });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Endpoint: Check authentication status
app.get('/api/auth/status/:pubkey', async (req, res) => {
const { pubkey } = req.params;
// Find session by pubkey
const session = Array.from(sessions.values())
.find(s => s.pubkey === pubkey);
if (session) {
res.json({
authenticated: true,
sessionToken: session.token
});
} else {
res.json({
authenticated: false
});
}
});
// Protected endpoint example
app.get('/api/user/profile', async (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'No authorization header' });
}
const token = authHeader.replace('Bearer ', '');
const session = sessions.get(token);
if (!session) {
return res.status(401).json({ error: 'Invalid session token' });
}
// Fetch user profile from Nostr
const profile = await portalClient.fetchProfile(session.pubkey);
res.json({
pubkey: session.pubkey,
profile
});
});
function generateRandomToken(): string {
return Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
}
app.listen(3001, () => {
console.log('Server running on port 3001');
});
Frontend Integration
React Example
import React, { useState, useEffect } from 'react';
import QRCode from 'qrcode';
function LoginPage() {
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [qrCode, setQrCode] = useState<string | null>(null);
const [checking, setChecking] = useState(false);
useEffect(() => {
// Generate auth URL when component mounts
fetch('/api/auth/start')
.then(res => res.json())
.then(async data => {
setAuthUrl(data.authUrl);
// Generate QR code
const qr = await QRCode.toDataURL(data.authUrl);
setQrCode(qr);
// Start checking for authentication
startAuthCheck(data.authUrl);
});
}, []);
function startAuthCheck(url: string) {
// Extract pubkey from URL (simplified)
const checkStatus = setInterval(async () => {
// In reality, you'd extract the pubkey from the auth flow
// This is simplified for demonstration
const res = await fetch('/api/auth/status/check');
const data = await res.json();
if (data.authenticated) {
clearInterval(checkStatus);
localStorage.setItem('sessionToken', data.sessionToken);
window.location.href = '/dashboard';
}
}, 2000);
}
return (
<div className="login-page">
<h1>Login with Nostr</h1>
{qrCode && (
<div className="qr-code">
<img src={qrCode} alt="Scan to login" />
<p>Scan with your Nostr wallet</p>
</div>
)}
{authUrl && (
<div className="direct-link">
<p>Or click here:</p>
<a href={authUrl} className="auth-button">
Open in Nostr Wallet
</a>
</div>
)}
<div className="loading">
<p>Waiting for authentication...</p>
</div>
</div>
);
}
Advanced: Using Subkeys
Subkeys allow delegated authentication where a user can grant limited permissions to subkeys:
const mainKey = 'user-main-public-key';
const subkeys = ['delegated-subkey-1', 'delegated-subkey-2'];
const authResponse = await client.authenticateKey(mainKey, subkeys);
if (authResponse.status.status === 'approved') {
console.log('Granted permissions:', authResponse.status.granted_permissions);
console.log('Session token:', authResponse.status.session_token);
}
Static Tokens (Long-lived Auth)
For long-lived authentication URLs that don't expire:
const staticToken = 'my-static-token-for-this-integration';
const authUrl = await client.newKeyHandshakeUrl(
(mainKey) => {
console.log('User authenticated:', mainKey);
},
staticToken // Static token parameter
);
// This URL can be reused multiple times
console.log('Reusable auth URL:', authUrl);
No-Request Mode
Skip the authentication challenge (just get the key handshake):
const authUrl = await client.newKeyHandshakeUrl(
(mainKey, relays) => {
// Just store the key, no auth challenge
console.log('Received key:', mainKey);
},
null, // No static token
true // noRequest = true
);
Security Best Practices
1. Always Verify Signatures
The Portal SDK handles signature verification, but always check the response status:
const authResponse = await client.authenticateKey(mainKey);
if (authResponse.status.status === 'approved') {
// Safe to proceed
} else {
// Don't grant access
}
2. Use Session Tokens
After authentication, issue session tokens instead of storing pubkeys directly:
// ā
Good
const sessionToken = generateSecureToken();
sessions.set(sessionToken, { pubkey: mainKey, expiresAt: Date.now() + 86400000 });
return sessionToken;
// ā Bad
// Storing pubkey as session identifier
3. Implement Session Expiration
function validateSession(token: string): boolean {
const session = sessions.get(token);
if (!session) return false;
if (session.expiresAt < Date.now()) {
sessions.delete(token);
return false;
}
return true;
}
4. Rate Limiting
Prevent abuse by rate-limiting auth URL generation:
const authAttempts = new Map<string, number>();
app.get('/api/auth/start', async (req, res) => {
const ip = req.ip;
const attempts = authAttempts.get(ip) || 0;
if (attempts > 10) {
return res.status(429).json({ error: 'Too many requests' });
}
authAttempts.set(ip, attempts + 1);
// Generate auth URL...
});
5. HTTPS Only in Production
// Enforce HTTPS in production
if (process.env.NODE_ENV === 'production' && req.protocol !== 'https') {
return res.redirect('https://' + req.hostname + req.url);
}
Troubleshooting
User Can't Open Auth URL
Problem: URL doesn't open in wallet
Solutions:
- Ensure user has a NWC-compatible wallet installed
- Try QR code instead of direct link
- Check URL format is correct (starts with
nostr:)
Authentication Never Completes
Problem: Callback never fires
Solutions:
- Check Portal daemon is connected to relays
- Verify user's wallet is online
- Check firewall/network settings
- Increase timeout if needed
"Declined" Status
Problem: User declined authentication
Solutions:
- Show clear explanation of what they're approving
- Allow user to retry
- Log the decline reason for debugging
Next Steps:
- Single Payments - Accept Lightning payments
- Profile Management - Work with user profiles
- JWT Tokens - Session management
Static Tokens & Physical Authentication
Use static tokens to create reusable authentication URLs for physical locations, enabling both online and in-person use cases.
What are Static Tokens?
Static tokens are unique identifiers you can embed in authentication URLs to create persistent, location-specific authentication points. Unlike regular authentication URLs that are single-use, static token URLs can be:
- Printed as QR codes on physical materials
- Written to NFC stickers for contactless authentication
- Reused indefinitely without regeneration
- Location-specific to track where requests originate
Why Use Static Tokens?
Static tokens enable Portal to work in the physical world, not just online:
ā Restaurant Tables - Print QR codes on tables for payment requests ā Office Access - NFC stickers on doors for authentication ā Event Check-in - Unique codes per entrance for tracking ā Vending Machines - Physical payment endpoints ā Hotel Rooms - Room-specific authentication for services ā Retail Checkout - Counter-specific payment requests
How It Works
1. Generate URL with Static Token
import { PortalSDK } from 'portal-sdk';
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
await client.connect();
await client.authenticate(process.env.AUTH_TOKEN);
// Create reusable URL with static token
const staticToken = 'table-14-restaurant-a';
const authUrl = await client.newKeyHandshakeUrl(
(mainKey, preferredRelays) => {
console.log(`Authentication from: ${staticToken}`);
console.log(`User: ${mainKey}`);
// Handle based on location
handleLocationAuth(staticToken, mainKey);
},
staticToken // Static token parameter
);
console.log('Reusable URL:', authUrl);
// This URL can be used multiple times!
2. The URL is Reusable
Unlike regular authentication URLs that expire after one use, static token URLs can be:
- Scanned multiple times
- Printed and distributed
- Embedded in physical objects
- Used by different users
3. Track Request Origin
function handleLocationAuth(location: string, userPubkey: string) {
// Parse location from static token
const [type, id, venue] = location.split('-');
switch (type) {
case 'table':
console.log(`User at table ${id} in ${venue}`);
// Send menu, track orders by table
break;
case 'door':
console.log(`Access request at ${id}`);
// Check permissions, unlock door
break;
case 'kiosk':
console.log(`Kiosk ${id} authentication`);
// Load user preferences
break;
}
}
Use Case: Restaurant Tables
Setup
class RestaurantService {
private client: PortalSDK;
private tableUrls = new Map<number, string>();
async generateTableQRCodes(tableCount: number) {
const urls: Array<{ table: number; url: string }> = [];
for (let tableNum = 1; tableNum <= tableCount; tableNum++) {
const staticToken = `table-${tableNum}-myrestaurant`;
const authUrl = await this.client.newKeyHandshakeUrl(
async (mainKey) => {
console.log(`Table ${tableNum}: User ${mainKey} authenticated`);
// Authenticate user
const authResponse = await this.client.authenticateKey(mainKey);
if (authResponse.status.status === 'approved') {
// Associate user with table
this.assignUserToTable(tableNum, mainKey);
// Send digital menu
this.sendMenu(mainKey);
}
},
staticToken
);
this.tableUrls.set(tableNum, authUrl);
urls.push({ table: tableNum, url: authUrl });
}
return urls;
}
async requestTablePayment(tableNum: number, amount: number) {
const userPubkey = this.getTableUser(tableNum);
if (!userPubkey) {
throw new Error('No user at this table');
}
return new Promise((resolve) => {
this.client.requestSinglePayment(
userPubkey,
[],
{
amount: amount * 1000,
currency: Currency.Millisats,
description: `Payment for Table ${tableNum}`
},
(status) => {
if (status.status === 'paid') {
console.log(`Table ${tableNum} paid!`);
this.clearTable(tableNum);
resolve(true);
}
}
);
});
}
private assignUserToTable(table: number, pubkey: string) {
// Implementation...
}
private getTableUser(table: number): string | null {
// Implementation...
return null;
}
private clearTable(table: number) {
// Implementation...
}
private sendMenu(pubkey: string) {
// Send menu items via Nostr direct messages
}
}
// Usage
const restaurant = new RestaurantService();
// Generate QR codes for 20 tables
const qrCodes = await restaurant.generateTableQRCodes(20);
// Print QR codes
for (const { table, url } of qrCodes) {
console.log(`Table ${table}:`);
await generateQRCodeImage(url, `table-${table}.png`);
}
// Later, when bill is ready
await restaurant.requestTablePayment(14, 45); // Table 14, 45 sats
Generating QR Codes
import QRCode from 'qrcode';
import fs from 'fs';
async function generateQRCodeImage(url: string, filename: string) {
// Generate PNG
await QRCode.toFile(filename, url, {
width: 400,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
});
console.log(`QR code saved: ${filename}`);
}
async function generateQRCodeSVG(url: string, filename: string) {
// Generate SVG for print quality
const svg = await QRCode.toString(url, { type: 'svg' });
fs.writeFileSync(filename, svg);
console.log(`QR code saved: ${filename}`);
}
// Generate for all tables
const tableUrl = await client.newKeyHandshakeUrl(handler, 'table-1');
await generateQRCodeImage(tableUrl, 'table-1-qr.png');
await generateQRCodeSVG(tableUrl, 'table-1-qr.svg'); // For printing
Use Case: Office Door Access
class DoorAccessSystem {
private client: PortalSDK;
private authorizedUsers = new Set<string>();
async setupDoor(doorId: string) {
const staticToken = `door-${doorId}`;
const nfcUrl = await this.client.newKeyHandshakeUrl(
async (mainKey) => {
console.log(`Access attempt at ${doorId} by ${mainKey}`);
// Authenticate user
const authResponse = await this.client.authenticateKey(mainKey);
if (authResponse.status.status === 'approved') {
// Check if user has access
if (this.authorizedUsers.has(mainKey)) {
console.log('ā
Access granted');
this.unlockDoor(doorId);
this.logAccess(doorId, mainKey, 'granted');
} else {
console.log('ā Access denied - not authorized');
this.logAccess(doorId, mainKey, 'denied');
}
}
},
staticToken
);
console.log(`Write this URL to NFC sticker for ${doorId}:`);
console.log(nfcUrl);
return nfcUrl;
}
addAuthorizedUser(pubkey: string) {
this.authorizedUsers.add(pubkey);
}
removeAuthorizedUser(pubkey: string) {
this.authorizedUsers.delete(pubkey);
}
private unlockDoor(doorId: string) {
// Send signal to smart lock
console.log(`Door ${doorId} unlocked for 5 seconds`);
}
private logAccess(doorId: string, user: string, result: 'granted' | 'denied') {
const log = {
timestamp: new Date(),
door: doorId,
user,
result
};
// Store in database
console.log('Access log:', log);
}
}
// Setup
const doorSystem = new DoorAccessSystem();
// Generate NFC URLs for different doors
await doorSystem.setupDoor('main-entrance');
await doorSystem.setupDoor('server-room');
await doorSystem.setupDoor('executive-office');
// Authorize users
doorSystem.addAuthorizedUser('user-pubkey-1'); // Main entrance
doorSystem.addAuthorizedUser('user-pubkey-2'); // All doors
Use Case: Event Entrances
Track which entrance each guest uses:
class EventCheckIn {
private client: PortalSDK;
private checkedInGuests = new Map<string, string>(); // pubkey -> entrance
async setupEntrances(entrances: string[]) {
const urls: Map<string, string> = new Map();
for (const entrance of entrances) {
const staticToken = `entrance-${entrance}`;
const url = await this.client.newKeyHandshakeUrl(
async (mainKey) => {
console.log(`Guest ${mainKey} at ${entrance}`);
const authResponse = await this.client.authenticateKey(mainKey);
if (authResponse.status.status === 'approved') {
// Check in guest
this.checkedInGuests.set(mainKey, entrance);
// Request ticket payment if needed
await this.verifyTicket(mainKey);
console.log(`ā
Checked in at ${entrance}`);
}
},
staticToken
);
urls.set(entrance, url);
}
return urls;
}
async verifyTicket(userPubkey: string) {
// Request Cashu ticket token
const result = await this.client.requestCashu(
userPubkey,
[],
'https://mint.example.com',
'vip',
1
);
if (result.status === 'success') {
// Burn to verify
await this.client.burnCashu(
'https://mint.example.com',
'vip',
result.token
);
return true;
}
return false;
}
getEntranceStats() {
const stats = new Map<string, number>();
for (const entrance of this.checkedInGuests.values()) {
stats.set(entrance, (stats.get(entrance) || 0) + 1);
}
return stats;
}
}
// Usage
const event = new EventCheckIn();
const entranceUrls = await event.setupEntrances([
'main-entrance',
'vip-entrance',
'backstage'
]);
// Print QR codes for each entrance
for (const [entrance, url] of entranceUrls) {
await generateQRCodeImage(url, `${entrance}-checkin.png`);
}
// Later: view stats
console.log('Check-in stats:', event.getEntranceStats());
// { 'main-entrance': 145, 'vip-entrance': 23, 'backstage': 8 }
NFC Integration Concepts
While Portal SDK doesn't directly handle NFC hardware, you can integrate with NFC-capable apps:
Writing to NFC
- Generate URL with static token
- Use NFC writing app to write the URL as an NDEF record
- Place sticker at physical location
Reading from NFC (Mobile App)
When a user's Nostr-compatible wallet app supports NFC:
- User taps phone on NFC sticker
- App reads the Portal authentication URL
- App opens the authentication flow
- User approves authentication
- Your backend receives the callback with the static token
- You know which physical location they're at
Example NFC Data Format
NDEF Record:
Type: URI
Data: nostr:nprofile1[...]static-token=table-5
Location-Based Routing
Use static tokens to route requests differently:
const locationHandlers = {
'table-': (token: string, user: string) => {
const tableNum = token.split('-')[1];
return handleRestaurantTable(tableNum, user);
},
'door-': (token: string, user: string) => {
const doorId = token.split('-')[1];
return handleDoorAccess(doorId, user);
},
'kiosk-': (token: string, user: string) => {
const kioskId = token.split('-')[1];
return handleKioskAuth(kioskId, user);
}
};
async function handleStaticTokenAuth(staticToken: string, userPubkey: string) {
// Find handler based on token prefix
for (const [prefix, handler] of Object.entries(locationHandlers)) {
if (staticToken.startsWith(prefix)) {
return handler(staticToken, userPubkey);
}
}
// Default handler
return handleGenericAuth(userPubkey);
}
// Generate URLs with routing
const tableUrl = await client.newKeyHandshakeUrl(
(mainKey) => handleStaticTokenAuth('table-5', mainKey),
'table-5'
);
const doorUrl = await client.newKeyHandshakeUrl(
(mainKey) => handleStaticTokenAuth('door-main', mainKey),
'door-main'
);
Security Considerations
1. Static Token Entropy
Use sufficiently random static tokens:
import crypto from 'crypto';
function generateStaticToken(prefix: string): string {
const random = crypto.randomBytes(16).toString('hex');
return `${prefix}-${random}`;
}
// Good: table-5-a3f9d2e1c4b8...
const token = generateStaticToken('table-5');
2. Token Rotation
Periodically rotate static tokens for sensitive locations:
class TokenManager {
private activeTokens = new Map<string, Date>();
async rotateToken(location: string, oldToken: string) {
const newToken = generateStaticToken(location);
// Generate new URL
const newUrl = await client.newKeyHandshakeUrl(
handler,
newToken
);
// Mark old token as deprecated
this.activeTokens.set(newToken, new Date());
// Give grace period before removing old
setTimeout(() => {
this.activeTokens.delete(oldToken);
}, 86400000); // 24 hours
return { token: newToken, url: newUrl };
}
}
3. Physical Security
- QR Codes: Consider using tamper-evident materials
- NFC Stickers: Use stickers with tamper detection
- Location: Place in supervised areas when possible
- Monitoring: Log all authentication attempts with timestamps
4. Access Control
Verify user permissions based on location:
const permissions = {
'table-1': ['menu', 'order', 'payment'],
'door-serverroom': ['authenticated-staff-only'],
'kiosk-lobby': ['check-in', 'directions']
};
async function checkPermission(staticToken: string, userPubkey: string, action: string) {
const requiredPerms = permissions[staticToken] || [];
const userPerms = await getUserPermissions(userPubkey);
return requiredPerms.some(perm => userPerms.includes(perm));
}
Analytics & Insights
Track physical location usage:
class LocationAnalytics {
private events: Array<{
timestamp: Date;
location: string;
user: string;
action: string;
}> = [];
logEvent(location: string, user: string, action: string) {
this.events.push({
timestamp: new Date(),
location,
user,
action
});
}
getLocationStats(timeframe: 'hour' | 'day' | 'week') {
// Aggregate by location
const stats = new Map<string, number>();
for (const event of this.events) {
stats.set(event.location, (stats.get(event.location) || 0) + 1);
}
return stats;
}
getPeakTimes(location: string) {
const hourCounts = new Array(24).fill(0);
for (const event of this.events) {
if (event.location === location) {
const hour = event.timestamp.getHours();
hourCounts[hour]++;
}
}
return hourCounts;
}
}
// Usage
const analytics = new LocationAnalytics();
// In your handler
const url = await client.newKeyHandshakeUrl(
(mainKey) => {
analytics.logEvent('table-5', mainKey, 'authenticated');
// ...rest of handler
},
'table-5'
);
// Later: analyze
console.log('Busiest tables:', analytics.getLocationStats('day'));
console.log('Table 5 peak hours:', analytics.getPeakTimes('table-5'));
Best Practices
1. Naming Conventions
Use consistent token naming:
// Good patterns:
'table-{number}-{venue}' // table-14-downtown
'door-{building}-{room}' // door-hq-serverroom
'kiosk-{location}-{number}' // kiosk-lobby-1
'entrance-{event}-{gate}' // entrance-concert-a
2. QR Code Printing
For physical QR codes:
await QRCode.toFile('table-5.png', url, {
width: 600, // Large enough to scan easily
margin: 4, // White border for reliability
errorCorrectionLevel: 'H' // High redundancy for damaged codes
});
3. Fallback Mechanisms
Provide alternatives if scanning fails:
// Include short URL or manual code
const shortCode = generateShortCode(staticToken);
console.log(`QR Code URL: ${url}`);
console.log(`Manual Code: ${shortCode}`);
// User can type: PORTALR5X9
4. Testing
Test all physical touchpoints:
# Generate test QR code
node scripts/generate-qr.js table-test
# Scan with multiple devices
# - iPhone with Nostr wallet
# - Android with Nostr wallet
# - Verify callback received
Why This Matters
Static tokens enable Portal to bridge the digital and physical worlds:
š Online ā Traditional web/mobile authentication šŖ In-Person ā QR codes, NFC, physical authentication
This makes Portal unique: one protocol, infinite touchpoints.
Whether someone is browsing your website or sitting at your restaurant, they authenticate the same way with the same identityātheir Nostr key.
Next Steps:
- Authentication Flow - Core authentication concepts
- Cashu Tokens - Physical tickets with Cashu
- Single Payments - Process payments from physical locations
Single Payments
Accept one-time Lightning Network payments from authenticated users.
Overview
Single payments are perfect for:
- One-time purchases
- Pay-per-use services
- Tips and donations
- Initial subscription payments
- Any transaction that happens once
How It Works
- User authenticates with your app
- You request a payment with amount and description
- Request is sent to user's Lightning wallet via Nostr
- User approves or rejects the payment
- You receive real-time status updates
- Payment settles instantly on Lightning Network
Basic Implementation
import { PortalSDK, Currency } from 'portal-sdk';
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
await client.connect();
await client.authenticate(process.env.AUTH_TOKEN);
const userPubkey = 'user-public-key-hex';
await client.requestSinglePayment(
userPubkey,
[], // subkeys (optional)
{
amount: 10000, // 10 sats (amount is in millisats)
currency: Currency.Millisats,
description: 'Premium subscription - 1 month'
},
(status) => {
console.log('Payment status:', status.status);
switch (status.status) {
case 'paid':
console.log('ā
Payment received!');
console.log('Preimage:', status.preimage);
// Grant access to service
break;
case 'user_approved':
console.log('ā³ User approved, processing...');
break;
case 'user_rejected':
console.log('ā User rejected payment');
console.log('Reason:', status.reason);
break;
case 'timeout':
console.log('ā±ļø Payment request timed out');
break;
case 'error':
console.log('ā Payment error:', status.reason);
break;
}
}
);
Payment Status Flow
User Receives Request
ā
[user_approved] (User approves in wallet)
ā
[user_success] (Wallet attempts payment)
ā
[paid] (Payment successful!)
Alternative flows:
user_rejected- User explicitly declinesuser_failed- Payment attempt failed (insufficient funds, routing failure, etc.)timeout- User doesn't respond in timeerror- System error occurred
Complete Example with Error Handling
import { PortalSDK, Currency } from 'portal-sdk';
class PaymentService {
private client: PortalSDK;
constructor(wsUrl: string, authToken: string) {
this.client = new PortalSDK({ serverUrl: wsUrl });
this.init(authToken);
}
private async init(authToken: string) {
await this.client.connect();
await this.client.authenticate(authToken);
}
async requestPayment(
userPubkey: string,
amountSats: number,
description: string
): Promise<{ success: boolean; preimage?: string; reason?: string }> {
return new Promise((resolve) => {
const timeoutMs = 60000; // 60 seconds
const timeout = setTimeout(() => {
resolve({
success: false,
reason: 'Payment request timed out'
});
}, timeoutMs);
this.client.requestSinglePayment(
userPubkey,
[],
{
amount: amountSats * 1000, // Convert sats to millisats
currency: Currency.Millisats,
description
},
(status) => {
if (status.status === 'paid') {
clearTimeout(timeout);
resolve({
success: true,
preimage: status.preimage
});
} else if (
status.status === 'user_rejected' ||
status.status === 'user_failed' ||
status.status === 'error'
) {
clearTimeout(timeout);
resolve({
success: false,
reason: status.reason || status.status
});
}
// For 'user_approved' and 'user_success', keep waiting
}
);
});
}
}
// Usage
const paymentService = new PaymentService(
process.env.PORTAL_WS_URL!,
process.env.PORTAL_AUTH_TOKEN!
);
const result = await paymentService.requestPayment(
userPubkey,
50, // 50 sats
'Premium features access'
);
if (result.success) {
console.log('Payment successful!');
console.log('Proof of payment:', result.preimage);
// Grant access to premium features
} else {
console.log('Payment failed:', result.reason);
// Show error message to user
}
Express.js API Example
import express from 'express';
import { PortalSDK, Currency } from 'portal-sdk';
const app = express();
app.use(express.json());
const portalClient = new PortalSDK({
serverUrl: process.env.PORTAL_WS_URL!
});
portalClient.connect().then(() => {
return portalClient.authenticate(process.env.PORTAL_AUTH_TOKEN!);
});
// Store pending payments
const pendingPayments = new Map<string, {
status: string;
preimage?: string;
resolve: (value: any) => void;
}>();
app.post('/api/payments/create', async (req, res) => {
const { userPubkey, amount, description } = req.body;
if (!userPubkey || !amount || !description) {
return res.status(400).json({ error: 'Missing required fields' });
}
const paymentId = generatePaymentId();
// Create promise for this payment
const paymentPromise = new Promise((resolve) => {
pendingPayments.set(paymentId, {
status: 'pending',
resolve
});
});
// Request payment
portalClient.requestSinglePayment(
userPubkey,
[],
{
amount: amount * 1000,
currency: Currency.Millisats,
description
},
(status) => {
const payment = pendingPayments.get(paymentId);
if (!payment) return;
if (status.status === 'paid') {
payment.status = 'paid';
payment.preimage = status.preimage;
payment.resolve({ success: true, preimage: status.preimage });
} else if (
status.status === 'user_rejected' ||
status.status === 'user_failed' ||
status.status === 'error'
) {
payment.status = 'failed';
payment.resolve({ success: false, reason: status.reason });
}
}
);
res.json({ paymentId });
});
app.get('/api/payments/:paymentId/status', async (req, res) => {
const { paymentId } = req.params;
const payment = pendingPayments.get(paymentId);
if (!payment) {
return res.status(404).json({ error: 'Payment not found' });
}
res.json({
status: payment.status,
preimage: payment.preimage
});
});
function generatePaymentId(): string {
return `pay_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
app.listen(3001);
Amount Conversion
Sats to Millisats
const sats = 10;
const millisats = sats * 1000;
await client.requestSinglePayment(userPubkey, [], {
amount: millisats,
currency: Currency.Millisats,
description: 'Payment'
});
Fiat to Sats
// You'll need to get exchange rate from an API
async function usdToSats(usd: number): Promise<number> {
const response = await fetch('https://api.coinbase.com/v2/exchange-rates?currency=BTC');
const data = await response.json();
const btcPerUsd = 1 / parseFloat(data.data.rates.USD);
const satsPerUsd = btcPerUsd * 100000000; // 100M sats per BTC
return Math.ceil(usd * satsPerUsd);
}
const usdAmount = 1.00; // $1 USD
const satsAmount = await usdToSats(usdAmount);
await client.requestSinglePayment(userPubkey, [], {
amount: satsAmount * 1000,
currency: Currency.Millisats,
description: '$1.00 USD payment'
});
Linking Payments to Subscriptions
You can link a single payment to a recurring subscription:
// First, create recurring subscription
const subscription = await client.requestRecurringPayment(
userPubkey,
[],
{
amount: 10000,
currency: Currency.Millisats,
recurrence: {
calendar: 'monthly',
first_payment_due: Timestamp.fromNow(86400),
max_payments: 12
},
expires_at: Timestamp.fromNow(3600)
}
);
console.log('Subscription ID:', subscription.subscription_id);
// Then request the first payment linked to this subscription
await client.requestSinglePayment(
userPubkey,
[],
{
amount: 10000,
currency: Currency.Millisats,
description: 'Monthly subscription - First payment',
subscription_id: subscription.subscription_id
},
(status) => {
if (status.status === 'paid') {
console.log('First subscription payment received!');
}
}
);
Invoice Payments
If you have a Lightning invoice from another source, you can request the user to pay it:
import { Timestamp } from 'portal-sdk';
await client.requestInvoicePayment(
userPubkey,
[],
{
amount: 5000,
currency: Currency.Millisats,
description: 'External invoice payment',
invoice: 'lnbc50n1...', // Your Lightning invoice
expires_at: Timestamp.fromNow(600) // 10 minutes
},
(status) => {
if (status.status === 'paid') {
console.log('Invoice paid!');
}
}
);
Best Practices
1. Clear Descriptions
// ā
Good - Clear and specific
await client.requestSinglePayment(userPubkey, [], {
amount: 50000,
currency: Currency.Millisats,
description: 'Premium Plan - 1 Month Access'
});
// ā Bad - Vague
await client.requestSinglePayment(userPubkey, [], {
amount: 50000,
currency: Currency.Millisats,
description: 'Payment'
});
2. Handle All Status Cases
client.requestSinglePayment(userPubkey, [], paymentRequest, (status) => {
switch (status.status) {
case 'paid':
// Grant access
break;
case 'user_approved':
// Show "Processing..."
break;
case 'user_rejected':
// Show "Payment declined"
break;
case 'user_failed':
// Show "Payment failed" + reason
break;
case 'timeout':
// Show "Request expired"
break;
case 'error':
// Log error, show generic message
break;
}
});
3. Store Payment Proofs
const payments = new Map<string, {
userPubkey: string;
amount: number;
description: string;
preimage: string;
timestamp: number;
}>();
client.requestSinglePayment(userPubkey, [], request, (status) => {
if (status.status === 'paid') {
payments.set(generatePaymentId(), {
userPubkey,
amount: request.amount,
description: request.description,
preimage: status.preimage!,
timestamp: Date.now()
});
}
});
4. Set Reasonable Timeouts
// Don't wait forever
const MAX_WAIT = 120000; // 2 minutes
const timeout = setTimeout(() => {
console.log('Payment request expired');
// Notify user
}, MAX_WAIT);
client.requestSinglePayment(userPubkey, [], request, (status) => {
if (status.status === 'paid' ||
status.status === 'user_rejected' ||
status.status === 'user_failed') {
clearTimeout(timeout);
}
});
5. Validate Amounts
function validatePaymentAmount(sats: number): boolean {
const MIN_SATS = 1;
const MAX_SATS = 1000000; // 0.01 BTC
return sats >= MIN_SATS && sats <= MAX_SATS;
}
if (!validatePaymentAmount(amount)) {
throw new Error('Invalid payment amount');
}
Troubleshooting
Payment Never Completes
Possible causes:
- User's wallet is offline
- Network connectivity issues
- Lightning routing failures
- Insufficient channel capacity
Solutions:
- Implement reasonable timeouts
- Show status to user ("Waiting for payment...")
- Allow users to retry
- Provide alternative payment methods
"User Rejected" Status
Causes:
- User explicitly declined
- Amount too high
- Insufficient funds
- User doesn't trust the request
Solutions:
- Show clear description of what they're paying for
- Display amount in both sats and fiat
- Build trust with clear branding
- Allow users to try again
Routing Failures
Causes:
- Recipient node unreachable
- No route with sufficient capacity
- Channel liquidity issues
Solutions:
- Ensure your NWC wallet has good connectivity
- Use a well-connected Lightning node
- Consider using a hosted Lightning service
- Set up multiple channels
Next Steps:
- Recurring Payments - Set up subscriptions
- Cashu Tokens - Issue tickets and vouchers
- Profile Management - Fetch user information
Recurring Payments
Set up subscription-based payments with customizable billing cycles.
Overview
Recurring payments enable subscription business models with:
- Automatic billing on custom schedules
- Monthly, weekly, or custom recurrence patterns
- Maximum payment limits
- User-controlled subscription management
Basic Implementation
import { PortalSDK, Currency, Timestamp } from 'portal-sdk';
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
await client.connect();
await client.authenticate(process.env.AUTH_TOKEN);
const userPubkey = 'user-public-key-hex';
const subscription = await client.requestRecurringPayment(
userPubkey,
[], // subkeys
{
amount: 10000, // 10 sats per payment
currency: Currency.Millisats,
recurrence: {
calendar: 'monthly', // or 'weekly', 'daily', etc.
first_payment_due: Timestamp.fromNow(86400), // 24 hours from now
max_payments: 12, // Optional: limit total payments
until: Timestamp.fromDate(new Date('2025-12-31')) // Optional: end date
},
expires_at: Timestamp.fromNow(3600) // Request expires in 1 hour
}
);
console.log('Subscription created!');
console.log('Subscription ID:', subscription.subscription_id);
console.log('Authorized amount:', subscription.authorized_amount);
console.log('Recurrence:', subscription.authorized_recurrence);
Recurrence Patterns
Portal supports the following calendar frequencies:
minutely- Every minute (for testing)hourly- Every hourdaily- Every dayweekly- Every weekmonthly- Every monthquarterly- Every 3 monthssemiannually- Every 6 monthsyearly- Every year
Monthly Subscription
{
calendar: 'monthly',
first_payment_due: Timestamp.fromNow(86400), // Start tomorrow
max_payments: 12 // 1 year
}
Weekly Subscription
{
calendar: 'weekly',
first_payment_due: Timestamp.fromNow(604800), // Start next week
max_payments: 52 // 1 year
}
Daily Subscription
{
calendar: 'daily',
first_payment_due: Timestamp.fromNow(86400), // Start tomorrow
max_payments: 30 // 30 days
}
Listening for Subscription Closures
Users can cancel subscriptions at any time. Listen for these events:
await client.listenClosedRecurringPayment((data) => {
console.log('Subscription closed!');
console.log('Subscription ID:', data.subscription_id);
console.log('User:', data.main_key);
console.log('Reason:', data.reason);
// Revoke access for this user
removeUserAccess(data.main_key);
});
Closing Subscriptions (Provider Side)
You can also close subscriptions from your side:
const message = await client.closeRecurringPayment(
userPubkey,
[],
subscriptionId
);
console.log(message); // "Subscription closed successfully"
Complete Subscription Service Example
class SubscriptionService {
private client: PortalSDK;
private subscriptions = new Map<string, {
userPubkey: string;
subscriptionId: string;
amount: number;
status: 'active' | 'cancelled';
}>();
constructor(wsUrl: string, authToken: string) {
this.client = new PortalSDK({ serverUrl: wsUrl });
this.init(authToken);
}
private async init(authToken: string) {
await this.client.connect();
await this.client.authenticate(authToken);
// Listen for user cancellations
await this.client.listenClosedRecurringPayment((data) => {
this.handleCancellation(data);
});
}
async createSubscription(
userPubkey: string,
plan: 'basic' | 'premium'
): Promise<string> {
const plans = {
basic: { amount: 10000, name: 'Basic Plan' },
premium: { amount: 50000, name: 'Premium Plan' }
};
const selectedPlan = plans[plan];
const result = await this.client.requestRecurringPayment(
userPubkey,
[],
{
amount: selectedPlan.amount,
currency: Currency.Millisats,
recurrence: {
calendar: 'monthly',
first_payment_due: Timestamp.fromNow(86400),
max_payments: 12
},
expires_at: Timestamp.fromNow(3600)
}
);
const subscriptionId = result.subscription_id;
// Store subscription
this.subscriptions.set(subscriptionId, {
userPubkey,
subscriptionId,
amount: selectedPlan.amount,
status: 'active'
});
return subscriptionId;
}
async cancelSubscription(subscriptionId: string) {
const sub = this.subscriptions.get(subscriptionId);
if (!sub) throw new Error('Subscription not found');
await this.client.closeRecurringPayment(
sub.userPubkey,
[],
subscriptionId
);
sub.status = 'cancelled';
}
private handleCancellation(data: any) {
const sub = this.subscriptions.get(data.subscription_id);
if (sub) {
sub.status = 'cancelled';
console.log(`User ${sub.userPubkey} cancelled subscription`);
// Revoke access, send notification, etc.
}
}
}
Next: Profile Management
Profile Management
Fetch and manage user profiles from the Nostr network.
Fetching User Profiles
const profile = await client.fetchProfile(userPubkey);
if (profile) {
console.log('Name:', profile.name);
console.log('Display Name:', profile.display_name);
console.log('Picture:', profile.picture);
console.log('About:', profile.about);
console.log('NIP-05:', profile.nip05);
}
Setting Your Service Profile
Publish your service's profile to Nostr:
await client.setProfile({
id: 'your-service-id',
pubkey: 'your-service-pubkey',
name: 'myservice',
display_name: 'My Awesome Service',
picture: 'https://myservice.com/logo.png',
about: 'Premium service powered by Portal',
nip05: 'verify@myservice.com'
});
Profile Fields
- id: Unique identifier
- pubkey: Nostr public key (hex)
- name: Username (no spaces)
- display_name: Display name (can have spaces)
- picture: Profile picture URL
- about: Bio/description
- nip05: Nostr verified identifier (like email)
Next: JWT Tokens
Cashu Tokens (Tickets)
Use Cashu ecash tokens as tickets, vouchers, or transferable access tokens in your application.
What is Cashu?
Cashu is an ecash protocol built on Bitcoin's Lightning Network. It provides:
- Privacy: Tokens are untraceable bearer instruments
- Offline Transfers: Can be sent peer-to-peer without internet
- Interoperability: Works across different applications
- Bitcoin-Backed: Each token is backed by real sats
- Blind Signatures: Mint cannot track token usage
Use Cases
Event Tickets
Issue tokens that grant access to events. Users present the token at entry, and you burn it to verify authenticity.
Vouchers & Gift Cards
Create tokens worth a specific amount that users can redeem for products or services.
Access Tokens
Grant temporary or permanent access to premium features using tokens.
Transferable Subscriptions
Allow users to share or resell access by transferring tokens.
How It Works
- Mint Tokens: Create Cashu tokens backed by sats
- Send to Users: Transfer tokens to authenticated users
- Users Hold Tokens: Users store tokens in their Cashu-compatible wallet
- Redeem/Burn: Verify and burn tokens when user accesses service
Requesting Cashu Tokens from Users
Ask users to send you Cashu tokens (e.g., as payment or ticket redemption):
import { PortalSDK } from 'portal-sdk';
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
await client.connect();
await client.authenticate(process.env.AUTH_TOKEN);
const userPubkey = 'user-public-key-hex';
// Request Cashu tokens from user
const result = await client.requestCashu(
userPubkey,
[], // subkeys
'https://mint.example.com', // mint URL
'sat', // unit (usually 'sat')
10000 // amount in millisats (10 sats)
);
if (result.status === 'success') {
console.log('Received Cashu token:', result.token);
// Now burn the token to claim the sats
const amount = await client.burnCashu(
'https://mint.example.com',
'sat',
result.token,
undefined // static_token (optional, for private mints)
);
console.log('Claimed amount:', amount);
} else if (result.status === 'insufficient_funds') {
console.log('User has insufficient funds');
} else if (result.status === 'rejected') {
console.log('User rejected the request');
}
Sending Cashu Tokens to Users
Send tokens directly to authenticated users:
// First, mint a token
const cashuToken = await client.mintCashu(
'https://mint.example.com', // mint URL
undefined, // static auth token (if mint requires it)
'sat', // unit
10000, // amount in millisats (10 sats)
'Event Ticket - VIP Access' // description
);
console.log('Minted token:', cashuToken);
// Send token to user
const userPubkey = 'user-public-key-hex';
const message = await client.sendCashuDirect(
userPubkey,
[], // subkeys
cashuToken
);
console.log('Sent to user:', message);
Complete Ticket Issuance Flow
Here's a complete example of issuing event tickets:
import { PortalSDK, Currency } from 'portal-sdk';
class TicketSystem {
private client: PortalSDK;
private mintUrl = 'https://mint.example.com';
constructor(wsUrl: string, authToken: string) {
this.client = new PortalSDK({ serverUrl: wsUrl });
this.init(authToken);
}
private async init(authToken: string) {
await this.client.connect();
await this.client.authenticate(authToken);
}
async issueTicket(userPubkey: string, ticketType: string, price: number): Promise<boolean> {
try {
// 1. Request payment from user
const paymentReceived = await this.requestPayment(userPubkey, price);
if (!paymentReceived) {
console.log('Payment failed or rejected');
return false;
}
// 2. Mint a Cashu token as the ticket
const ticket = await this.client.mintCashu(
this.mintUrl,
undefined,
'sat',
price,
`Ticket: ${ticketType}`
);
// 3. Send ticket to user
await this.client.sendCashuDirect(userPubkey, [], ticket);
console.log('Ticket issued successfully!');
return true;
} catch (error) {
console.error('Failed to issue ticket:', error);
return false;
}
}
private async requestPayment(userPubkey: string, amount: number): Promise<boolean> {
return new Promise((resolve) => {
this.client.requestSinglePayment(
userPubkey,
[],
{
amount,
currency: Currency.Millisats,
description: 'Event ticket purchase'
},
(status) => {
if (status.status === 'paid') {
resolve(true);
} else if (status.status === 'user_rejected' || status.status === 'timeout') {
resolve(false);
}
}
);
});
}
async verifyAndRedeemTicket(userPubkey: string): Promise<boolean> {
try {
// Request the ticket back from user
const result = await this.client.requestCashu(
userPubkey,
[],
this.mintUrl,
'sat',
1000 // ticket value
);
if (result.status === 'success') {
// Burn the token to verify it and prevent reuse
const amount = await this.client.burnCashu(
this.mintUrl,
'sat',
result.token
);
console.log('Ticket verified and redeemed!');
return true;
} else {
console.log('Invalid or already used ticket');
return false;
}
} catch (error) {
console.error('Ticket verification failed:', error);
return false;
}
}
}
// Usage
const ticketSystem = new TicketSystem(
process.env.PORTAL_WS_URL!,
process.env.PORTAL_AUTH_TOKEN!
);
// Issue a VIP ticket
await ticketSystem.issueTicket(
userPubkey,
'VIP Access',
50000 // 50 sats
);
// Later, when user arrives at event
await ticketSystem.verifyAndRedeemTicket(userPubkey);
Voucher System Example
Create a gift voucher system:
class VoucherSystem {
private client: PortalSDK;
private mintUrl = 'https://mint.example.com';
async createVoucher(value: number, description: string): Promise<string> {
// Mint a token worth the voucher value
const voucher = await this.client.mintCashu(
this.mintUrl,
undefined,
'sat',
value,
`Voucher: ${description}`
);
return voucher;
}
async sendVoucher(recipientPubkey: string, voucher: string) {
await this.client.sendCashuDirect(recipientPubkey, [], voucher);
console.log('Voucher sent to recipient');
}
async redeemVoucher(userPubkey: string, voucherValue: number): Promise<number> {
const result = await this.client.requestCashu(
userPubkey,
[],
this.mintUrl,
'sat',
voucherValue
);
if (result.status === 'success') {
// Burn and claim the value
const amount = await this.client.burnCashu(
this.mintUrl,
'sat',
result.token
);
return amount;
}
return 0;
}
}
// Create and send a $5 voucher (assuming 1 sat = $0.0001)
const voucher = await voucherSystem.createVoucher(50000, '$5 Store Credit');
await voucherSystem.sendVoucher(recipientPubkey, voucher);
// Redeem voucher
const value = await voucherSystem.redeemVoucher(userPubkey, 50000);
console.log('Voucher redeemed for:', value, 'millisats');
API Reference
mintCashu()
Mint new Cashu tokens from a mint:
await client.mintCashu(
mintUrl: string, // Mint URL
staticAuthToken?: string, // Optional: auth token for private mints
unit: string, // Usually 'sat'
amount: number, // Amount in millisats
description?: string // Optional description
): Promise<string>
Returns: Cashu token as a string
burnCashu()
Burn (redeem) a Cashu token at a mint:
await client.burnCashu(
mintUrl: string, // Mint URL
unit: string, // Usually 'sat'
token: string, // Cashu token to burn
staticAuthToken?: string // Optional: auth token for private mints
): Promise<number>
Returns: Amount claimed in millisats
requestCashu()
Request Cashu tokens from a user:
await client.requestCashu(
recipientKey: string, // User's public key
subkeys: string[], // Optional subkeys
mintUrl: string, // Mint URL
unit: string, // Usually 'sat'
amount: number // Amount to request
): Promise<CashuResponseStatus>
Returns:
{
status: 'success' | 'insufficient_funds' | 'rejected',
token?: string // If status is 'success'
reason?: string // If status is 'rejected'
}
sendCashuDirect()
Send Cashu tokens directly to a user:
await client.sendCashuDirect(
mainKey: string, // User's public key
subkeys: string[], // Optional subkeys
token: string // Cashu token to send
): Promise<string>
Returns: Success message
Setting Up Your Own Mint
For complete control over token issuance and custom ticket types, run your own Cashu mint using Portal's enhanced CDK implementation.
Full Guide: See Running a Custom Mint for detailed instructions on:
- Docker deployment with
getportal/cdk-mintd - Creating custom units (VIP, General, etc.)
- Adding metadata and images to tokens
- Event ticket configuration
- Authentication and security
Quick Start:
docker pull getportal/cdk-mintd:latest
# Configure and run - see full guide for details
Public Mints
If you don't want to run your own mint, you can use public Cashu mints:
- https://mint.minibits.cash
- https://mint.bitcoinmints.com
- https://stablenut.umint.cash
To find more mints, check: https://bitcoinmints.com/
Security Considerations
1. Token Storage
Cashu tokens are bearer instruments. Anyone with the token can spend it:
// ā Don't log tokens in production
console.log('Token:', cashuToken);
// ā Don't store in plain text
fs.writeFileSync('token.txt', cashuToken);
// ā
Handle securely
// Only send directly to users, don't store
2. Double-Spending Prevention
Always burn tokens immediately after receiving:
const result = await client.requestCashu(user, [], mintUrl, 'sat', 1000);
if (result.status === 'success') {
// Burn immediately to prevent reuse
await client.burnCashu(mintUrl, 'sat', result.token);
}
3. Mint Trust
You must trust the mint operator:
- Use reputable, well-known mints for production
- Consider running your own mint for full control
- Diversify across multiple mints for redundancy
4. Amount Validation
Always validate amounts before minting:
function validateAmount(amount: number): boolean {
return amount > 0 && amount <= 100000000; // Max 100k sats
}
if (validateAmount(ticketPrice)) {
await client.mintCashu(mintUrl, undefined, 'sat', ticketPrice);
}
Troubleshooting
"Insufficient funds" Error
User's wallet doesn't have enough Cashu tokens at that mint:
- They may need to mint tokens first
- They may have tokens at a different mint
Mint Connection Issues
try {
await client.mintCashu(mintUrl, undefined, 'sat', 1000);
} catch (error) {
console.error('Mint error:', error);
// Try alternative mint or notify user
}
Token Already Spent
If burning fails, the token may have already been redeemed:
- Tokens can only be spent once
- Implement proper tracking to prevent this
Next Steps:
- JWT Tokens - Session management with JWTs
- Single Payments - Accept Lightning payments
- Production Deployment - Deploy securely
Running a Custom Cashu Mint
Run your own Cashu mint to issue custom tokens and tickets with Portal's enhanced CDK implementation.
Why Run Your Own Mint?
Running your own Cashu mint gives you:
- Custom Units: Create custom ticket types beyond just "sats"
- Full Control: Complete control over issuance and redemption
- Privacy: Tokens are untraceable, users maintain privacy
- Branding: Add custom images and metadata to tokens
- Event Tickets: Perfect for issuing event tickets, vouchers, or access tokens
- No Intermediaries: Direct issuance without third parties
Portal's Enhanced CDK
Portal maintains a fork of Cashu CDK with enhanced features:
- Custom Units: Define multiple ticket types with different denominations
- Metadata: Add titles, descriptions, and images to each unit
- Event Information: Include date and location for event tickets
- Authentication: Built-in static token authentication
- Portal Wallet Backend: Integration with Portal's Lightning backend
Quick Start with Docker
1. Pull the Docker Image
docker pull getportal/cdk-mintd:latest
2. Create Configuration File
Simple Configuration (Recommended for Getting Started)
Create config.toml with a basic fungible token:
[info]
url = "https://mint.yourdomain.com"
listen_host = "127.0.0.1"
listen_port = 3338
[mint_info]
name = "My Cashu Mint"
description = "A simple Cashu mint"
[ln]
ln_backend = "portalwallet"
mint_max = 100000
melt_max = 100000
[portal_wallet.supported_units]
sat = 32 # Standard satoshi unit
[portal_wallet.unit_info.sat]
title = "Satoshi"
description = "Standard Bitcoin satoshi token"
show_individually = false # Show as fungible currency
url = "https://yourdomain.com"
[portal_wallet.unit_info.sat.kind.Event]
date = "01/01/1970"
location = "Worldwide"
[database]
engine = "sqlite"
[auth]
[auth.method.Static]
token = "your-secure-static-token"
mint_max_bat = 50
enabled_mint = true
enabled_melt = true
enabled_swap = false
enabled_restore = false
enabled_check_proof_state = false
Advanced Configuration (Event Tickets)
For event ticketing with multiple custom units, create config.toml:
[info]
url = "https://mint.yourdomain.com"
listen_host = "0.0.0.0"
listen_port = 3338
[mint_info]
name = "My Custom Mint"
description = "Cashu mint for custom tokens and tickets"
[ln]
ln_backend = "portalwallet"
mint_max = 100000 # Maximum minting amount
melt_max = 100000 # Maximum melting amount
[portal_wallet]
# Define custom units
[portal_wallet.supported_units]
vip = 32 # 32 denomination keysets
general = 32
early_bird = 32
# Configure each unit's metadata
[portal_wallet.unit_info.vip]
title = "VIP Pass"
description = "VIP access with all perks"
show_individually = true
front_card_background = "https://yourdomain.com/images/vip-front.png"
back_card_background = "https://yourdomain.com/images/vip-back.png"
[portal_wallet.unit_info.vip.kind.Event]
date = "2026-12-31"
location = "New York, USA"
[portal_wallet.unit_info.general]
title = "General Admission"
description = "General admission ticket"
show_individually = true
front_card_background = "https://yourdomain.com/images/general-front.png"
back_card_background = "https://yourdomain.com/images/general-back.png"
[portal_wallet.unit_info.general.kind.Event]
date = "2026-12-31"
location = "New York, USA"
[portal_wallet.unit_info.early_bird]
title = "Early Bird"
description = "Special early bird pricing"
show_individually = true
front_card_background = "https://yourdomain.com/images/early-front.png"
back_card_background = "https://yourdomain.com/images/early-back.png"
[portal_wallet.unit_info.early_bird.kind.Event]
date = "2026-12-31"
location = "New York, USA"
[database]
engine = "sqlite"
[auth]
[auth.method.Static]
token = "your-secure-static-token-here"
mint_max_bat = 50
enabled_mint = true
enabled_melt = true
enabled_swap = false
enabled_restore = false
enabled_check_proof_state = false
3. Run the Mint
The simplest way to run the mint:
docker run -d \
--name cashu-mint \
-p 3338:3338 \
-v $(pwd)/config.toml:/config.toml:ro \
-v mint-data:/data \
getportal/cdk-mintd:latest
With Custom Paths:
docker run -d \
--name cashu-mint \
-p 3338:3338 \
-v /path/to/config.toml:/config.toml:ro \
-v /path/to/data:/data \
getportal/cdk-mintd:latest
Options Explained:
-p 3338:3338- Expose port 3338-v config.toml:/config.toml:ro- Mount config file (read-only)-v mint-data:/data- Persist databasegetportal/cdk-mintd:latest- Use latest image
Quick Test (Temporary):
For testing without persistence:
docker run --rm \
--name test-mint \
-p 3338:3338 \
-v $(pwd)/config.toml:/config.toml:ro \
getportal/cdk-mintd:latest
4. Verify Mint is Running
# Check logs
docker logs cashu-mint
# Test mint endpoint (locally)
curl http://localhost:3338/v1/info
# Should return mint info JSON
Example response:
{
"name": "My Cashu Mint",
"description": "A simple Cashu mint",
"pubkey": "...",
"version": "...",
"nuts": {...}
}
Configuration Options
Mint Information
[info]
url = "https://mint.yourdomain.com" # Public URL of your mint
listen_host = "0.0.0.0" # IP to bind to (0.0.0.0 for all)
listen_port = 3338 # Port to listen on
[mint_info]
name = "My Mint" # Displayed name
description = "Description of mint" # Mint description
Lightning Backend
[ln]
ln_backend = "portalwallet" # Use Portal's wallet backend
mint_max = 100000 # Max amount per mint operation (msats)
melt_max = 100000 # Max amount per melt operation (msats)
Custom Units
Fungible Tokens (like normal currency)
[portal_wallet.supported_units]
sat = 32 # Or any other name
[portal_wallet.unit_info.sat]
title = "Satoshi"
description = "Standard fungible token"
show_individually = false # Important: false for fungible tokens
url = "https://yourdomain.com"
[portal_wallet.unit_info.sat.kind.Event]
date = "01/01/1970"
location = "Worldwide"
Non-Fungible Tickets
Define custom token types for tickets:
[portal_wallet.supported_units]
vip = 32 # 32 denominations (powers of 2)
general = 32
[portal_wallet.unit_info.vip]
title = "VIP Ticket"
description = "Full access pass"
show_individually = true # Important: true for individual tickets
front_card_background = "https://example.com/vip-front.png"
back_card_background = "https://example.com/vip-back.png"
# Event-specific metadata
[portal_wallet.unit_info.vip.kind.Event]
date = "2026-12-31"
location = "City, Country"
Key Difference:
show_individually = false- Tokens are fungible (like money)show_individually = true- Each token is unique (like tickets)
Authentication
Protect minting operations with a static token:
[auth]
[auth.method.Static]
token = "your-secret-token"
mint_max_bat = 50 # Max batch size
enabled_mint = true # Allow minting
enabled_melt = true # Allow melting
enabled_swap = false # Disable swapping
enabled_restore = false # Disable restore
enabled_check_proof_state = false
Database
[database]
engine = "sqlite" # SQLite for simplicity
# Or use PostgreSQL:
# engine = "postgres"
# connection_string = "postgresql://user:pass@localhost/mintdb"
Using Your Custom Mint
Minting Tokens (Fungible)
import { PortalSDK } from 'portal-sdk';
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
await client.connect();
await client.authenticate(process.env.AUTH_TOKEN);
// Mint fungible tokens (like satoshis)
const token = await client.mintCashu(
'http://localhost:3338',
'your-static-token', // From config.toml
'sat', // Unit name from config
10, // Amount (10 sats worth)
'Payment for service'
);
// Send to user
await client.sendCashuDirect(userPubkey, [], token);
Minting Tickets (Non-Fungible)
// Mint a VIP ticket
const vipToken = await client.mintCashu(
'http://localhost:3338',
'your-static-token', // Static token for authentication
'vip', // Custom unit
1, // Amount (1 VIP ticket)
'VIP access for event'
);
// Send to user
await client.sendCashuDirect(userPubkey, [], vipToken);
Burning/Redeeming Tokens
// Request token back from user
const result = await client.requestCashu(
userPubkey,
[],
'http://localhost:3338',
'sat', // Same unit type as minted
10 // Amount
);
if (result.status === 'success') {
// Burn to verify and claim
const amount = await client.burnCashu(
'http://localhost:3338',
'sat',
result.token,
'your-static-token' // From config.toml
);
console.log('Valid token! Claimed:', amount);
// Grant access or process payment
}
For Tickets (Non-Fungible):
const result = await client.requestCashu(
userPubkey,
[],
'http://localhost:3338',
'vip', // Ticket unit
1 // Amount
);
if (result.status === 'success') {
const amount = await client.burnCashu(
'http://localhost:3338',
'vip',
result.token,
'your-static-token'
);
console.log('Valid VIP ticket! Granting access...');
// Grant access to VIP area
}
Building from Source
To build from Portal's CDK fork:
1. Clone the Repository
git clone https://github.com/PortalTechnologiesInc/cdk-mintd.git
cd cdk-mintd
2. Build with Cargo
cargo build --release
3. Run the Mint
MINT_CONFIG=config.toml \
MNEMONIC_FILE=mnemonic.txt \
./target/release/cdk-mintd
4. Or Build with Nix
nix build
./result/bin/cdk-mintd
Production Deployment
With Reverse Proxy (Nginx)
server {
listen 443 ssl http2;
server_name mint.yourdomain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3338;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
With Docker Compose
version: '3.8'
services:
cashu-mint:
image: getportal/cdk-mintd:latest
container_name: cashu-mint
ports:
- "3338:3338"
volumes:
- ./config.toml:/config.toml:ro
- mint-data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3338/v1/info"]
interval: 30s
timeout: 10s
retries: 3
volumes:
mint-data:
Start with:
docker-compose up -d
Environment Variables
The mint looks for /config.toml by default. You can override with:
docker run -d \
-e CONFIG_PATH=/custom/path/config.toml \
-e RUST_LOG=debug \
-v $(pwd)/config.toml:/custom/path/config.toml:ro \
-v mint-data:/data \
getportal/cdk-mintd:latest
Available Variables:
CONFIG_PATH- Path to config file (default:/config.toml)RUST_LOG- Log level (error,warn,info,debug,trace)DATA_DIR- Data directory (default:/data)
Use Cases
Event Ticketing
Create different ticket tiers with custom images:
[portal_wallet.supported_units]
vip = 32
general = 32
student = 32
[portal_wallet.unit_info.vip]
title = "VIP Pass"
description = "Full access with backstage pass"
front_card_background = "https://event.com/vip-front.png"
back_card_background = "https://event.com/vip-back.png"
[portal_wallet.unit_info.vip.kind.Event]
date = "2026-08-15"
location = "Convention Center, NYC"
Gift Vouchers
[portal_wallet.supported_units]
voucher_50 = 32
voucher_100 = 32
[portal_wallet.unit_info.voucher_50]
title = "$50 Gift Card"
description = "Redeemable for any product"
show_individually = true
Access Tokens
[portal_wallet.supported_units]
premium = 32
basic = 32
[portal_wallet.unit_info.premium]
title = "Premium Access"
description = "6 months premium membership"
Security Best Practices
1. Protect Configuration File
# Set read-only permissions
chmod 600 config.toml
# Mount as read-only in Docker
docker run -v $(pwd)/config.toml:/config.toml:ro ...
2. Rotate Static Tokens
Regularly update your authentication token:
[auth.method.Static]
token = "new-secure-token"
3. Use HTTPS
Always run behind HTTPS in production:
- Let's Encrypt certificates
- Reverse proxy (Nginx, Caddy)
- Valid SSL/TLS configuration
4. Rate Limiting
Implement rate limiting at the reverse proxy level:
limit_req_zone $binary_remote_addr zone=mint:10m rate=10r/s;
location / {
limit_req zone=mint burst=20;
proxy_pass http://localhost:3338;
}
5. Monitor the Mint
# Check mint logs
docker logs -f cashu-mint
# Monitor database size
du -sh mint-data/
# Watch for errors
docker logs cashu-mint 2>&1 | grep ERROR
Monitoring & Maintenance
Health Checks
# Check mint info
curl https://mint.yourdomain.com/v1/info
# Check keysets
curl https://mint.yourdomain.com/v1/keys
# Check specific unit
curl https://mint.yourdomain.com/v1/keys/vip
Backup
# Backup database (most important!)
docker exec cashu-mint sqlite3 /data/mint.db ".backup '/data/backup.db'"
docker cp cashu-mint:/data/backup.db ./backup.db
# Or backup entire data directory
docker run --rm \
-v mint-data:/data \
-v $(pwd)/backups:/backup \
alpine tar czf /backup/mint-backup-$(date +%Y%m%d).tar.gz /data
# Backup config
cp config.toml /secure/backup/location/
Logs
# View logs
docker logs cashu-mint
# Follow logs
docker logs -f cashu-mint
# Save logs
docker logs cashu-mint > mint.log 2>&1
Troubleshooting
Mint Won't Start
# Check logs
docker logs cashu-mint
# Verify config is mounted
docker exec cashu-mint cat /config.toml
# Check permissions
docker exec cashu-mint ls -la /config.toml /data
Can't Mint Tokens
- Verify static token is correct
- Check
enabled_mint = truein config - Ensure Lightning backend is configured
- Check mint hasn't reached
mint_max
Authentication Errors
# Test with curl
curl -X POST https://mint.yourdomain.com/v1/mint/quote/bolt11 \
-H "Authorization: Bearer your-static-token" \
-H "Content-Type: application/json" \
-d '{"amount": 100, "unit": "sat"}'
Database Issues
# Check database file
docker exec cashu-mint ls -lh /app/data/
# Verify permissions
docker exec cashu-mint ls -la /app/data/
Advanced: Multiple Units
Create a complex ticket system:
[portal_wallet.supported_units]
early_bird = 32
regular = 32
vip = 32
sponsor = 32
[portal_wallet.unit_info.early_bird]
title = "Early Bird Special"
description = "Limited early bird pricing"
show_individually = true
front_card_background = "https://event.com/early-front.png"
back_card_background = "https://event.com/early-back.png"
[portal_wallet.unit_info.early_bird.kind.Event]
date = "2026-06-01"
location = "Conference Center"
[portal_wallet.unit_info.regular]
title = "Regular Admission"
description = "Standard entry ticket"
show_individually = true
front_card_background = "https://event.com/regular-front.png"
back_card_background = "https://event.com/regular-back.png"
[portal_wallet.unit_info.regular.kind.Event]
date = "2026-06-01"
location = "Conference Center"
# ... VIP and Sponsor configurations ...
Resources
- Portal's CDK Fork: github.com/PortalTechnologiesInc/cdk-mintd
- Cashu Protocol: cashu.space
- Docker Image: hub.docker.com/r/getportal/cdk-mintd
Next Steps:
- Cashu Tokens Guide - Using tokens with Portal SDK
- Production Deployment - Deploy securely
- Troubleshooting - Common issues
JWT Tokens (Session Management)
Verify JWT tokens issued by user wallet apps for API authentication.
Overview
While Cashu tokens are used for tickets and transferable access, JWT tokens are for:
- API authentication (user's wallet issues the token, you verify it)
- Session management
- Short-lived access tokens
- Stateless authentication
Important: In most cases, JWT tokens are issued by the user's wallet app and verified by your business. You don't typically issue JWTs yourself - the user's wallet does this after authentication.
Primary Use Case: Verifying JWT Tokens
The main use of JWT tokens in Portal is verification. After a user authenticates through their wallet app, they receive a JWT token from their wallet. Your business then verifies this token to authenticate API requests.
Verifying JWT Tokens
const publicKey = 'your-service-public-key';
const token = 'jwt-token-from-user';
try {
const claims = await client.verifyJwt(publicKey, token);
console.log('Token is valid for user:', claims.target_key);
// Grant access
} catch (error) {
console.error('Invalid or expired token');
// Deny access
}
Advanced: Issuing JWT Tokens (Less Common)
In some cases, you may want to issue JWT tokens yourself (e.g., for service-to-service authentication):
const targetPubkey = 'user-public-key';
const durationHours = 24; // Token valid for 24 hours
const jwtToken = await client.issueJwt(targetPubkey, durationHours);
console.log('JWT:', jwtToken);
However, in most authentication flows, the user's wallet app will issue the JWT token after they approve the authentication request.
API Authentication Middleware
async function authenticateRequest(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
try {
const claims = await portalClient.verifyJwt(
process.env.SERVICE_PUBKEY,
token
);
req.userPubkey = claims.target_key;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
// Protected route
app.get('/api/user/data', authenticateRequest, (req, res) => {
res.json({ userPubkey: req.userPubkey, data: '...' });
});
Typical Authentication Flow with JWTs
- User initiates authentication through your app
- User's Nostr wallet app generates and signs a JWT
- JWT is sent to your application
- Your application verifies the JWT using Portal
- Grant API access based on verified identity
Best Practices
- Verify, don't issue: Let user wallets issue tokens, you just verify them
- Check expiration: Validate JWT expiration times
- Secure transmission: Always use HTTPS
- Don't log tokens: Never log tokens in production
- Use for APIs: Perfect for stateless API authentication
Next: Relay Management
Relay Management
Dynamically manage Nostr relays in your Portal instance.
Overview
Relays are Nostr servers that store and forward messages. Portal connects to multiple relays for redundancy and better message delivery.
Adding Relays
const relayUrl = 'wss://relay.damus.io';
const addedRelay = await client.addRelay(relayUrl);
console.log('Added relay:', addedRelay);
Removing Relays
const relayUrl = 'wss://relay.damus.io';
const removedRelay = await client.removeRelay(relayUrl);
console.log('Removed relay:', removedRelay);
Popular Relays
Here are some reliable public relays:
wss://relay.damus.io- Popular, well-maintainedwss://relay.snort.social- Fast and reliablewss://nos.lol- Good for paymentswss://relay.nostr.band- Large relay networkwss://nostr.wine- Paid relay (more reliable)
Best Practices
- Use 3-5 relays: Balance between redundancy and bandwidth
- Geographic diversity: Choose relays in different locations
- Mix free and paid: Paid relays often have better uptime
- Monitor connectivity: Remove relays that are consistently offline
- User preferences: Respect user's preferred relays from handshake
Relay Configuration Example
class RelayManager {
private client: PortalSDK;
private activeRelays = new Set<string>();
async setupDefaultRelays() {
const defaultRelays = [
'wss://relay.damus.io',
'wss://relay.snort.social',
'wss://nos.lol'
];
for (const relay of defaultRelays) {
try {
await this.client.addRelay(relay);
this.activeRelays.add(relay);
console.log('ā
Connected to', relay);
} catch (error) {
console.error('ā Failed to connect to', relay);
}
}
}
async addUserRelays(preferredRelays: string[]) {
// Add user's preferred relays from handshake
for (const relay of preferredRelays) {
if (!this.activeRelays.has(relay)) {
try {
await this.client.addRelay(relay);
this.activeRelays.add(relay);
} catch (error) {
console.error('Failed to add user relay:', relay);
}
}
}
}
}
Next: API Reference
Frequently Asked Questions
General Questions
What is Portal?
Portal is a toolkit for businesses to process payments, authenticate users, issue tickets and much more. Portal is based on freedom tech (Nostr, Lightning Network and Cashu) all without intermediaries.
Do users need a Nostr account?
Yes, users need a nostr key to interact with businesses using Portal. A key is generated automatically by the Portal app, or it can be imported.
Is Portal free?
Portal is free and open-source (MIT license).
Technical Questions
Can I use Portal without Docker?
Yes! You can build and run from source using Cargo. See Building from Source.
Do I need to run a Lightning node?
Not necessarily. You can use Nostr Wallet Connect (NWC) with a hosted wallet service like Alby, or use the built-in wallet powered by the Breez SDK.
How do I handle user sessions?
Use JWT tokens issued by Portal for session management. See JWT Tokens Guide.
Payment Questions
What happens if a payment fails?
The user receives a status update, and you can handle it in your callback. No funds are lost.
Can I issue refunds?
Yes, but you'll need to initiate a reverse payment to the user's Lightning wallet.
How long do payments take?
Lightning fast.
Security Questions
Is Portal secure?
Portal uses cryptographic signatures for authentication and doesn't handle private keys.
Where are private keys stored?
Your Portal instance has its own private key. User private keys are stored in the secure storage and never leave their devices
Can users be tracked?
Portal is designed with privacy in mind. Nostr relays don't require registration, and Lightning payments don't expose personal information.
Troubleshooting
"Connection refused" error
- Check Portal daemon is running:
docker ps - Verify correct port (default: 3000)
- Check firewall settings
Users can't authenticate
- Verify users have a compatible Nostr wallet
- Check relay connectivity
- Ensure NOSTR_KEY is set correctly
Payments not working
- Verify NWC_URL is configured
- Check wallet has sufficient balance
- Test wallet connectivity separately
Need more help? Check Troubleshooting Guide
Glossary
Nostr Terms
Nostr: Notes and Other Stuff Transmitted by Relays. A decentralized protocol for social media and messaging.
npub: Nostr public key in bech32 format (starts with "npub1..."). This is a user's public identifier.
nsec: Nostr secret/private key in bech32 format (starts with "nsec1..."). Must be kept secret.
Relay: A server that stores and forwards Nostr events. Anyone can run a relay.
Event: A signed message in Nostr. Everything is an event (posts, messages, authentication, etc.).
NIP: Nostr Implementation Possibility. These are protocol specifications (like "NIPs" = RFCs for Nostr).
NIP-05: A verification method linking a Nostr key to a domain name (like email).
Subkey: A delegated key that can act on behalf of a main key with limited permissions.
Lightning Network Terms
Lightning Network: A Layer 2 protocol built on Bitcoin for fast, cheap transactions.
Satoshi (sat): The smallest unit of Bitcoin. 1 BTC = 100,000,000 sats.
Millisat (msat): One thousandth of a satoshi. Lightning Network's smallest unit.
Invoice: A payment request in Lightning Network format (starts with "lnbc...").
Preimage: Proof of payment in Lightning Network. Hash of this is in the invoice.
Channel: A payment channel between two Lightning nodes allowing off-chain transactions.
NWC: Nostr Wallet Connect. A protocol for requesting payments via Nostr.
Routing: Finding a path through the Lightning Network to deliver a payment.
Portal Terms
Portal SDK Daemon: The WebSocket server that handles Nostr and Lightning operations.
Auth Token: Secret token used to authenticate with the Portal daemon API.
Key Handshake: Initial exchange where user shares their public key and preferred relays.
Challenge-Response: Authentication method where you challenge a key and verify the signature.
Single Payment: One-time Lightning payment.
Recurring Payment: Subscription-based payment with automatic billing.
Cashu: An ecash protocol built on Lightning. Used for tickets/vouchers in Portal.
Mint: A Cashu mint that issues and redeems ecash tokens.
Cashu Terms
Cashu Token: A bearer token representing sats, issued by a mint.
Mint: A server that issues and redeems Cashu tokens.
Blind Signature: Cryptographic technique allowing mints to sign tokens without knowing their value.
Burn: Redeeming a Cashu token back to sats at a mint.
Technical Terms
WebSocket: A protocol for real-time bidirectional communication.
Hex: Hexadecimal format (base 16). Nostr keys are often shown in hex.
Bech32: An encoding format used for Bitcoin addresses and Nostr keys.
JWT: JSON Web Token. Used for session management and API authentication.
Session Token: A temporary token proving a user's authenticated session.
Stream ID: Identifier for a long-running operation (like payment status updates).
Back to: Documentation Home
Troubleshooting
Common issues and solutions when working with Portal.
Connection Issues
"Connection refused" or "ECONNREFUSED"
Cause: Portal daemon is not running or not accessible.
Solutions:
# Check if Portal is running
docker ps | grep portal
# Check if port 3000 is listening
netstat -tlnp | grep 3000
# or
lsof -i :3000
# Test connection
curl http://localhost:3000/health
# Check Docker logs
docker logs portal-sdk-daemon
"Connection timeout"
Cause: Network issues or firewall blocking connection.
Solutions:
- Check firewall rules
- Verify correct URL (ws:// vs wss://)
- Increase timeout in SDK config
- Check network connectivity
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws',
connectTimeout: 30000 // Increase to 30 seconds
});
Authentication Issues
"Authentication failed"
Cause: Invalid or mismatched AUTH_TOKEN.
Solutions:
# Verify token in Docker container
docker exec portal-sdk-daemon env | grep AUTH_TOKEN
# Verify token in your code
console.log('Using token:', process.env.PORTAL_AUTH_TOKEN?.substring(0, 10) + '...');
# Regenerate token if needed
NEW_TOKEN=$(openssl rand -hex 32)
echo "New token: $NEW_TOKEN"
User Can't Authenticate
Cause: User doesn't have compatible wallet or URL doesn't open.
Solutions:
- Verify user has Alby, Mutiny, or compatible NWC wallet
- Try QR code instead of direct link
- Check relay connectivity
- Verify NOSTR_KEY is set correctly
# Test relay connectivity
wscat -c wss://relay.damus.io
# Verify NOSTR_KEY format (64 hex chars)
echo $NOSTR_KEY | wc -c # Should output 65 (64 + newline)
Payment Issues
Payments Never Complete
Cause: Multiple possible reasons.
Solutions:
// Add timeout handling
const TIMEOUT = 120000; // 2 minutes
const timeout = setTimeout(() => {
console.log('Payment timed out');
// Handle timeout
}, TIMEOUT);
client.requestSinglePayment(user, [], request, (status) => {
if (status.status === 'paid') {
clearTimeout(timeout);
// Success
}
});
"User rejected" or "User failed"
Cause: User declined or payment failed.
Common reasons:
- Insufficient funds
- Lightning routing failure
- User manually declined
- Channel capacity issues
Solutions:
- Show clear payment details upfront
- Ensure reasonable amounts
- Provide fallback payment options
- Check NWC wallet has sufficient balance
NWC Not Working
Cause: Invalid or expired NWC_URL.
Solutions:
# Verify NWC_URL format
echo $NWC_URL
# Should start with: nostr+walletconnect://
# Test NWC connection separately
# Use a tool like Alby to verify NWC string works
# Regenerate NWC URL in wallet settings
Relay Issues
"Cannot connect to relays"
Cause: Relay URLs invalid or relays offline.
Solutions:
# Test relay connectivity
for relay in wss://relay.damus.io wss://relay.snort.social wss://nos.lol; do
echo "Testing $relay"
timeout 5 wscat -c $relay && echo "ā
Connected" || echo "ā Failed"
done
# Update NOSTR_RELAYS in .env
NOSTR_RELAYS=wss://relay.damus.io,wss://relay.snort.social,wss://nos.lol
Messages Not Delivering
Cause: Not enough relays or user not on same relays.
Solutions:
- Use user's preferred relays from handshake
- Connect to 3-5 popular relays
- Add paid relays for better reliability
// Add user's preferred relays
client.newKeyHandshakeUrl(async (mainKey, preferredRelays) => {
// Add user's relays
for (const relay of preferredRelays) {
try {
await client.addRelay(relay);
} catch (e) {
console.error('Failed to add relay:', relay);
}
}
});
Docker Issues
Container Won't Start
# Check logs
docker logs portal-sdk-daemon
# Check environment variables
docker inspect portal-sdk-daemon | grep -A 20 Env
# Verify Docker image
docker images | grep portal
# Remove and recreate
docker rm -f portal-sdk-daemon
docker run -d --name portal-sdk-daemon \
-p 3000:3000 \
-e AUTH_TOKEN=$AUTH_TOKEN \
-e NOSTR_KEY=$NOSTR_KEY \
getportal/sdk-daemon:latest
Health Check Failing
# Manual health check
curl http://localhost:3000/health
# Check if service is listening
docker exec portal-sdk-daemon netstat -tlnp
# Check for errors in logs
docker logs portal-sdk-daemon --tail 50
TypeScript SDK Issues
"Cannot find module 'portal-sdk'"
# Reinstall dependencies
rm -rf node_modules package-lock.json
npm install
# Verify installation
npm list portal-sdk
# Check import path
# ā
Correct
import { PortalSDK } from 'portal-sdk';
# ā Incorrect
import { PortalSDK } from './portal-sdk';
WebSocket Errors in Browser
// Check if using correct protocol
const url = window.location.protocol === 'https:'
? 'wss://portal.example.com/ws'
: 'ws://localhost:3000/ws';
const client = new PortalSDK({ serverUrl: url });
TypeScript Errors
# Ensure TypeScript is installed
npm install --save-dev typescript
# Check tsconfig.json settings
{
"compilerOptions": {
"esModuleInterop": true,
"skipLibCheck": true
}
}
Performance Issues
Slow Response Times
Causes:
- Too many relays
- Slow relay connections
- Network latency
Solutions:
- Reduce to 3-5 fast relays
- Use geographically close relays
- Monitor relay performance
High Memory Usage
# Check Docker stats
docker stats portal-sdk-daemon
# Restart container
docker restart portal-sdk-daemon
# Adjust Docker memory limits
docker run -d --name portal \
--memory=512m \
--memory-swap=1g \
...
Debug Mode
Enable verbose logging for troubleshooting:
// In your SDK code
const client = new PortalSDK({
serverUrl: 'ws://localhost:3000/ws'
});
// Log all messages
client.on({
onConnected: () => console.log('[DEBUG] Connected'),
onDisconnected: () => console.log('[DEBUG] Disconnected'),
onError: (e) => console.error('[DEBUG] Error:', e)
});
For Docker daemon:
# Set log level
docker run -d \
-e RUST_LOG=debug \
...
getportal/sdk-daemon:latest
# View debug logs
docker logs -f portal-sdk-daemon
Getting Help
If you're still having issues:
- Check existing issues: GitHub Issues
- Search documentation: Use Ctrl+F or search feature
- Enable debug logging: Capture detailed logs
- Create minimal reproduction: Simplify to smallest failing example
- Open an issue: Include:
- Portal version
- SDK version
- Environment (OS, Node version)
- Complete error messages
- Steps to reproduce
Back to: Documentation Home
Contributing to Portal
We welcome contributions to Portal! This guide will help you get started.
Ways to Contribute
- Report bugs - Open an issue on GitHub
- Suggest features - Share your ideas
- Improve documentation - Fix typos, add examples
- Submit code - Fix bugs or implement features
- Answer questions - Help others in discussions
Development Setup
- Fork the repository
git clone https://github.com/YOUR_USERNAME/portal.git
cd portal
- Set up development environment
# Using Nix (recommended)
nix develop
# Or manually with Cargo
cargo build
- Run tests
cargo test
-
Make your changes
-
Run linting
cargo fmt
cargo clippy
- Submit a pull request
Code Style
- Follow Rust conventions
- Use
cargo fmtbefore committing - Fix
cargo clippywarnings - Write tests for new features
Documentation
When adding features:
- Update relevant documentation
- Add code examples
- Update the changelog
Questions?
- Open a GitHub issue
- Join community discussions
- Read the existing documentation
Thank you for contributing to Portal!