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

  1. Deploy the Portal SDK Daemon: Run the REST API server using Docker
  2. Integrate the TypeScript SDK: Connect your application to Portal
  3. Authenticate Users: Generate authentication URLs for users to connect
  4. Process Payments: Request single or recurring payments
  5. 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:

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:

  1. Decentralized Identity: Users control their own keys and identity
  2. No Central Server: Communication happens through distributed relays
  3. Censorship Resistance: No single point of control
  4. Privacy: Direct peer-to-peer messaging
  5. Interoperability: Standard protocol that works across applications

Nostr in Portal's Authentication Flow

When a user authenticates with Portal:

  1. Your application generates an authentication challenge
  2. The challenge is published to Nostr relays
  3. The user's wallet (like Alby, Mutiny, or others) picks up the challenge
  4. The user approves or denies the authentication
  5. The response is published back to Nostr
  6. 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)

  1. Payment Channels: Two parties open a channel by creating a special Bitcoin transaction
  2. Off-Chain Transactions: They can then make unlimited instant transactions between each other
  3. Network of Channels: Payments can route through multiple channels to reach any destination
  4. 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

  1. Payment Request: Your app requests a payment through Portal
  2. Nostr Message: Request is sent to the user via Nostr
  3. Wallet Notification: User's wallet shows the payment request
  4. User Approval: User approves or denies the payment
  5. Lightning Payment: Wallet sends payment via Lightning Network
  6. 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:

  1. Deploy the Portal SDK Daemon using Docker
  2. Install the TypeScript SDK
  3. 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-this with a strong random token
  • your-nostr-private-key-hex with 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

  1. The script will output an authentication URL
  2. Open the URL in a browser (or share it with a user)
  3. If you have Alby or another NWC-compatible wallet, it will ask you to approve the connection
  4. 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:

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

VariableDescriptionExample
AUTH_TOKENSecret token for API authenticationrandom-secret-token-12345
NOSTR_KEYNostr private key in hex format5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab7a

Optional Variables

VariableDescriptionDefault
NWC_URLNostr Wallet Connect URL for processing paymentsNone
NOSTR_SUBKEY_PROOFProof for Nostr subkey delegationNone
NOSTR_RELAYSComma-separated list of relay URLsCommon 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

  1. Never commit secrets: Don't include AUTH_TOKEN or NOSTR_KEY in version control
  2. Use strong tokens: Generate cryptographically secure random tokens
  3. Restrict network access: Use firewalls to limit who can connect
  4. Enable HTTPS: Use a reverse proxy with SSL/TLS
  5. Regular updates: Keep the Docker image updated
  6. 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:

  1. Use a Lightning wallet that supports NWC (Alby, Mutiny, etc.)
  2. Navigate to wallet settings
  3. Find "Nostr Wallet Connect" or "Wallet Connect String"
  4. 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-maintained
  • wss://relay.snort.social - Fast and reliable
  • wss://nos.lol - Good for payments
  • wss://relay.nostr.band - Large relay network
  • wss://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

  1. 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
  1. 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

  1. Make changes to source code
  2. Run tests: cargo test
  3. Check code: cargo clippy
  4. Format code: cargo fmt
  5. Build: cargo build --release
  6. Test locally: Run the binary
  7. 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 class
  • Currency - Currency enum
  • Timestamp - 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.js
  • isomorphic-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


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 daemon
  • connectTimeout (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:

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

  1. Always use try-catch for async operations
  2. Check status codes in callbacks
  3. Implement retry logic for critical operations
  4. Log errors with context
  5. 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

  1. Generate Auth URL: Your app creates an authentication URL
  2. User Opens URL: User clicks the link (opens in their Nostr wallet)
  3. Wallet Prompts: Wallet asks user to approve the authentication
  4. Key Handshake: Wallet sends user's public key and preferred relays
  5. Challenge-Response: Your app sends a challenge, user signs it
  6. 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:

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

  1. Generate URL with static token
  2. Use NFC writing app to write the URL as an NDEF record
  3. Place sticker at physical location

Reading from NFC (Mobile App)

When a user's Nostr-compatible wallet app supports NFC:

  1. User taps phone on NFC sticker
  2. App reads the Portal authentication URL
  3. App opens the authentication flow
  4. User approves authentication
  5. Your backend receives the callback with the static token
  6. 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:

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

  1. User authenticates with your app
  2. You request a payment with amount and description
  3. Request is sent to user's Lightning wallet via Nostr
  4. User approves or rejects the payment
  5. You receive real-time status updates
  6. 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 declines
  • user_failed - Payment attempt failed (insufficient funds, routing failure, etc.)
  • timeout - User doesn't respond in time
  • error - 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 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 hour
  • daily - Every day
  • weekly - Every week
  • monthly - Every month
  • quarterly - Every 3 months
  • semiannually - Every 6 months
  • yearly - 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

  1. Mint Tokens: Create Cashu tokens backed by sats
  2. Send to Users: Transfer tokens to authenticated users
  3. Users Hold Tokens: Users store tokens in their Cashu-compatible wallet
  4. 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:

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

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 database
  • getportal/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 = true in 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


Next Steps:

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

  1. User initiates authentication through your app
  2. User's Nostr wallet app generates and signs a JWT
  3. JWT is sent to your application
  4. Your application verifies the JWT using Portal
  5. Grant API access based on verified identity

Best Practices

  1. Verify, don't issue: Let user wallets issue tokens, you just verify them
  2. Check expiration: Validate JWT expiration times
  3. Secure transmission: Always use HTTPS
  4. Don't log tokens: Never log tokens in production
  5. 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);

Here are some reliable public relays:

  • wss://relay.damus.io - Popular, well-maintained
  • wss://relay.snort.social - Fast and reliable
  • wss://nos.lol - Good for payments
  • wss://relay.nostr.band - Large relay network
  • wss://nostr.wine - Paid relay (more reliable)

Best Practices

  1. Use 3-5 relays: Balance between redundancy and bandwidth
  2. Geographic diversity: Choose relays in different locations
  3. Mix free and paid: Paid relays often have better uptime
  4. Monitor connectivity: Remove relays that are consistently offline
  5. 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:

  1. Check existing issues: GitHub Issues
  2. Search documentation: Use Ctrl+F or search feature
  3. Enable debug logging: Capture detailed logs
  4. Create minimal reproduction: Simplify to smallest failing example
  5. 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

  1. Fork the repository
git clone https://github.com/YOUR_USERNAME/portal.git
cd portal
  1. Set up development environment
# Using Nix (recommended)
nix develop

# Or manually with Cargo
cargo build
  1. Run tests
cargo test
  1. Make your changes

  2. Run linting

cargo fmt
cargo clippy
  1. Submit a pull request

Code Style

  • Follow Rust conventions
  • Use cargo fmt before committing
  • Fix cargo clippy warnings
  • 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!