Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Welcome to Portal

Published docs: https://portaltechnologiesinc.github.io/lib/

Portal is a Nostr-based authentication and payment SDK allowing applications to authenticate users and process payments through Nostr and Lightning Network.

You can integrate via the official SDKs (TypeScript, Java) or run the Portal API yourself and use the protocol directly.

What is Portal?

Portal uses Nostr and the Lightning Network to provide:

  • Decentralized authentication — Users sign in with Nostr keys; no passwords or email.
  • Lightning payments — Single and recurring payments, real-time status.
  • Privacy-first — No third parties, no data collection; direct peer-to-peer where possible.
  • Tickets & vouchers — Issue Cashu ecash tokens to authenticated users.

How to use Portal

  1. Integrate with an SDK — Use the JavaScript SDK or Java SDK (same docs, switch tab): connect to a Portal endpoint with an auth token and call the API.
  2. Or run the API — Self-host or develop locally: run the Portal API (Docker or build from source); then use an SDK or connect to the WebSocket API.
  3. Auth, payments, tickets — Generate auth URLs (users approve with Nostr wallet); request single or recurring Lightning payments; issue Cashu tokens.

Key features

  • Authentication — Nostr key handshake, main keys and subkeys, no passwords.
  • Payments — Single and recurring Lightning; Cashu mint/burn/request/send.
  • Profiles — Fetch and set Nostr profiles; NIP-05.
  • Sessions — Issue and verify JWTs for API access.
  • SDKs — TypeScript/JavaScript and JVM; React Native bindings.

Getting started

Docs overview

SectionFor
Getting StartedQuick Start, Docker, env vars, building from source.
SDKSDK install, usage, config, errors.
GuidesAuth, payments, profiles, Cashu, JWT, relays.
ResourcesFAQ, glossary, troubleshooting, contributing.

Open source

Portal is open source (MIT where noted). Contributions are welcome.

Next: Quick Start

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 started with Portal in a few minutes: use the SDK (JavaScript or Java) to integrate, or run the Portal API with Docker.

What you need

  • JavaScript: Node.js 18+ — Java: Java 17+ (Installation)
  • A Portal endpoint (URL) and auth token.
    • If someone gives you a URL and token (hosted Portal or teammate), use those.
    • If not, you’ll run Portal locally with Docker in the next section and use ws://localhost:3000/ws and your chosen token.

Step 1: Install the SDK

JavaScript
npm install portal-sdk
Java

Add Jitpack and the dependency to your build.gradle:

repositories { maven { url 'https://jitpack.io' } }
dependencies { implementation 'com.github.PortalTechnologiesInc:java-sdk:0.1.0' }

See Installation for Maven and full details.

Step 2: Get a Portal endpoint and token

Option A — You have an endpoint and token
Use them as serverUrl and in authenticate(token) below. Skip to Step 3.

Option B — Run Portal locally (Docker)

You need a Nostr private key (hex). Generate one (e.g. nostrtool.com → Key Generator, or nak key generate), then:

docker run -d -p 3000:3000 \
  -e PORTAL__AUTH__AUTH_TOKEN=my-secret-token \
  -e PORTAL__NOSTR__PRIVATE_KEY=your-nostr-private-key-hex \
  getportal/sdk-daemon:latest

Check: curl http://localhost:3000/healthOK.

Use:

  • Endpoint: ws://localhost:3000/ws
  • Token: my-secret-token

Step 3: Connect and authenticate

JavaScript

Create a file (e.g. portal-demo.js or portal-demo.ts):

import { PortalSDK } from 'portal-sdk';

async function main() {
  const client = new PortalSDK({
    serverUrl: process.env.PORTAL_URL || 'ws://localhost:3000/ws',
  });

  await client.connect();
  await client.authenticate(process.env.PORTAL_AUTH_TOKEN || 'my-secret-token');

  console.log('Connected to Portal');
}
main().catch(console.error);

Run it (with env set if you use Option B):

PORTAL_AUTH_TOKEN=my-secret-token node portal-demo.js
Java

Create a PortalSDK with your health and WebSocket endpoints, then connect with your auth token:

var portalSDK = new PortalSDK("http://localhost:3000/health", "ws://localhost:3000/ws");
portalSDK.connect("my-secret-token");

See Basic Usage for full usage.

Step 4: Your first flow — user auth URL

Add a call that generates an auth URL for a user. When they open it and approve (e.g. with an NWC wallet), your callback runs:

JavaScript
const url = await client.newKeyHandshakeUrl((mainKey, preferredRelays) => {
  console.log('User authenticated with key:', mainKey);
});

console.log('Share this URL with your user:');
console.log(url);

Run the script, open the URL in a browser, and approve in your wallet. You should see the user’s key in the console.

Java

Use KeyHandshakeUrlRequest and sendCommand to get an auth URL; handle the response in the callback. See API Reference and Authentication guide.

Done. You’ve connected to Portal via the SDK.

What’s next?

Common issues

IssueWhat to do
Connection refusedPortal not running or wrong URL. For local Docker: docker ps and use ws://localhost:3000/ws.
Auth failedToken must match the one Portal was started with (e.g. PORTAL__AUTH__AUTH_TOKEN in Docker).
Invalid Nostr keyUse hex format for PORTAL__NOSTR__PRIVATE_KEY; convert nsec with e.g. nak decode nsec ....

More: Troubleshooting, FAQ.

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 PORTAL__AUTH__AUTH_TOKEN=your-secret-token \
  -e PORTAL__NOSTR__PRIVATE_KEY=your-nostr-private-key-hex \
  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:
      - PORTAL__AUTH__AUTH_TOKEN=${PORTAL__AUTH__AUTH_TOKEN}
      - PORTAL__NOSTR__PRIVATE_KEY=${PORTAL__NOSTR__PRIVATE_KEY}
      - PORTAL__WALLET__LN_BACKEND=${PORTAL__WALLET__LN_BACKEND:-none}
      - PORTAL__WALLET__NWC__URL=${PORTAL__WALLET__NWC__URL:-}
      - PORTAL__NOSTR__RELAYS=${PORTAL__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:

PORTAL__AUTH__AUTH_TOKEN=your-secret-token-here
PORTAL__NOSTR__PRIVATE_KEY=your-nostr-private-key-hex
PORTAL__WALLET__LN_BACKEND=nwc
PORTAL__WALLET__NWC__URL=nostr+walletconnect://...
PORTAL__NOSTR__RELAYS=wss://relay.damus.io,wss://relay.snort.social

Start the service:

docker-compose up -d

Environment Variables

Required Variables

VariableDescriptionExample
PORTAL__AUTH__AUTH_TOKENSecret token for API authenticationrandom-secret-token-12345
PORTAL__NOSTR__PRIVATE_KEYNostr private key in hex format5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab7a

Optional Variables

VariableDescriptionDefault
PORTAL__WALLET__LN_BACKENDWallet backend: none, nwc, or breeznone
PORTAL__WALLET__NWC__URLNostr Wallet Connect URL (when ln_backend=nwc)None
PORTAL__NOSTR__SUBKEY_PROOFProof for Nostr subkey delegationNone
PORTAL__NOSTR__RELAYSComma-separated list of relay URLsFrom config

Configuration Examples

Development Setup

For local development with minimal configuration:

docker run --rm --name portal-dev \
  -p 3000:3000 \
  -e PORTAL__AUTH__AUTH_TOKEN=dev-token \
  -e PORTAL__NOSTR__PRIVATE_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 PORTAL__AUTH__AUTH_TOKEN=$(openssl rand -hex 32) \
  -e PORTAL__NOSTR__PRIVATE_KEY=$(cat /secure/nostr-key.hex) \
  -e PORTAL__WALLET__LN_BACKEND=nwc \
  -e PORTAL__WALLET__NWC__URL="nostr+walletconnect://..." \
  -e PORTAL__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 PORTAL__AUTH__AUTH_TOKEN=your-token \
  -e PORTAL__NOSTR__PRIVATE_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 PORTAL__AUTH__AUTH_TOKEN=your-token \
  -e PORTAL__NOSTR__PRIVATE_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 Dockerfile

Build the image from the repository’s Dockerfile:

# Clone the repository
git clone https://github.com/PortalTechnologiesInc/lib.git
cd lib

# Build the image
docker build -t portal-rest:latest .

# Run it
docker run -d -p 3000:3000 \
  -e PORTAL__AUTH__AUTH_TOKEN=your-secret-token \
  -e PORTAL__NOSTR__PRIVATE_KEY=your-nostr-private-key-hex \
  portal-rest:latest

With Docker Compose, create docker-compose.yml:

services:
  portal:
    build: .
    image: portal-rest:latest
    container_name: portal-sdk-daemon
    ports:
      - "3000:3000"
    environment:
      - PORTAL__AUTH__AUTH_TOKEN=${PORTAL__AUTH__AUTH_TOKEN}
      - PORTAL__NOSTR__PRIVATE_KEY=${PORTAL__NOSTR__PRIVATE_KEY}
      - PORTAL__WALLET__LN_BACKEND=${PORTAL__WALLET__LN_BACKEND:-none}
      - PORTAL__WALLET__NWC__URL=${PORTAL__WALLET__NWC__URL:-}
      - PORTAL__NOSTR__RELAYS=${PORTAL__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 with your values, then:

docker compose up -d

Building from Source with Nix

Portal uses Nix for reproducible builds:

# Clone the repository
git clone https://github.com/PortalTechnologiesInc/lib.git
cd lib

# 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 PORTAL__AUTH__AUTH_TOKEN=your-token \
  -e PORTAL__NOSTR__PRIVATE_KEY=your-key \
  getportal/sdk-daemon:latest

Security Considerations

  1. Never commit secrets: Don’t include PORTAL__AUTH__AUTH_TOKEN or PORTAL__NOSTR__PRIVATE_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 PORTAL__AUTH__AUTH_TOKEN=token \
  -e PORTAL__NOSTR__PRIVATE_KEY=key \
  getportal/sdk-daemon:latest

Next Steps:

Environment Variables

Configure your Portal SDK Daemon (portal-rest) with environment variables. The binary reads config from ~/.portal-rest/config.toml and overrides via PORTAL__<SECTION>__<KEY> env vars.

Config file and PORTAL__* env vars

  • Config file: ~/.portal-rest/config.toml (created with defaults if missing). Copy from example.config.toml in the portal-rest crate.
  • Env overrides: Any setting can be overridden with PORTAL__<SECTION>__<KEY>=value (double underscores). Section and key match the TOML structure.
Config keyEnv variableDescription
info.listen_portPORTAL__INFO__LISTEN_PORTPort the API listens on (default 3000).
auth.auth_tokenPORTAL__AUTH__AUTH_TOKENAPI auth token. Required for clients to connect.
nostr.private_keyPORTAL__NOSTR__PRIVATE_KEYNostr private key in hex format. Required.
nostr.relaysPORTAL__NOSTR__RELAYSComma-separated relay URLs.
nostr.subkey_proofPORTAL__NOSTR__SUBKEY_PROOFProof for Nostr subkey delegation (optional).
wallet.ln_backendPORTAL__WALLET__LN_BACKENDnone, nwc, or breez.
wallet.nwc.urlPORTAL__WALLET__NWC__URLNostr Wallet Connect URL (when ln_backend=nwc).
wallet.breez.api_keyPORTAL__WALLET__BREEZ__API_KEYBreez API key (when ln_backend=breez).
wallet.breez.storage_dirPORTAL__WALLET__BREEZ__STORAGE_DIRBreez storage directory.
wallet.breez.mnemonicPORTAL__WALLET__BREEZ__MNEMONICBreez mnemonic (when ln_backend=breez).

Run from config:

portal-rest   # uses ~/.portal-rest/config.toml

Required variables

PORTAL__AUTH__AUTH_TOKEN

Description: Authentication token for API access. Clients must provide this token when connecting to the WebSocket API.

Security: Generate a cryptographically secure random token. Never commit this to version control.

# Generate a secure token
openssl rand -hex 32

PORTAL__NOSTR__PRIVATE_KEY

Description: Your Portal instance’s Nostr private key in hexadecimal format. Used to sign messages and authenticate on the Nostr network.

Format: Hex format (64 characters). Convert from nsec with: nak decode nsec1your-key-here

Optional variables

PORTAL__WALLET__NWC__URL (for payments)

Description: Nostr Wallet Connect URL for processing Lightning Network payments. Set PORTAL__WALLET__LN_BACKEND=nwc when using this.

Without NWC: Portal can still handle authentication and generate payment requests, but users will need to pay invoices manually.

PORTAL__NOSTR__RELAYS

Description: Comma-separated list of Nostr relay URLs. Default comes from config file.

Recommended relays: wss://relay.damus.io, wss://relay.snort.social, wss://nos.lol, wss://relay.nostr.band

Configuration Examples

Minimal Development Setup

Bare minimum for local development:

PORTAL__AUTH__AUTH_TOKEN=dev-token-change-in-production \
PORTAL__NOSTR__PRIVATE_KEY=5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab7a \
portal-rest

Full Production Setup

Complete configuration for production deployment:

# Required
export PORTAL__AUTH__AUTH_TOKEN=$(openssl rand -hex 32)
export PORTAL__NOSTR__PRIVATE_KEY=5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab7a

# Payment processing
export PORTAL__WALLET__LN_BACKEND=nwc
export PORTAL__WALLET__NWC__URL=nostr+walletconnect://...

# Network configuration
export PORTAL__NOSTR__RELAYS=wss://relay.damus.io,wss://relay.snort.social,wss://nos.lol,wss://relay.nostr.band

portal-rest

Using Environment Files

.env file (for docker-compose)

Create a .env file in your project directory:

# Portal Configuration (use PORTAL__* format)
PORTAL__AUTH__AUTH_TOKEN=your-secret-token
PORTAL__NOSTR__PRIVATE_KEY=your-nostr-key-hex
PORTAL__WALLET__LN_BACKEND=nwc
PORTAL__WALLET__NWC__URL=nostr+walletconnect://your-nwc-url
PORTAL__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 PORTAL__AUTH__AUTH_TOKEN=$PORTAL__AUTH__AUTH_TOKEN \
  -e PORTAL__NOSTR__PRIVATE_KEY=$PORTAL__NOSTR__PRIVATE_KEY \
  -e PORTAL__WALLET__LN_BACKEND=nwc \
  -e PORTAL__WALLET__NWC__URL=$PORTAL__WALLET__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/PORTAL__AUTH__AUTH_TOKEN=.*/PORTAL__AUTH__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 PORTAL__

# 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 PORTAL__AUTH__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 $PORTAL__NOSTR__PRIVATE_KEY | wc -c
# Should output: 65 (64 chars + newline)

# Verify it's valid hex
echo $PORTAL__NOSTR__PRIVATE_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 $PORTAL__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 portal-rest

# Build in release mode (optimized)
cargo build --package portal-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 portal-cli --release
cargo build --package portal-rates --release

Build the JavaScript/TypeScript client

cd crates/portal-rest/clients/ts

# Install dependencies
npm install

# Build the client
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 portal-rest'

# Run with environment variables
PORTAL__AUTH__AUTH_TOKEN=dev-token \
PORTAL__NOSTR__PRIVATE_KEY=your-key-hex \
cargo run --package portal-rest

# Or use the env.example (if it exists)
# cp crates/portal-rest/env.example crates/portal-rest/.env
# Edit .env with your values
# source crates/portal-rest/.env
cargo run --package portal-rest

Run Tests

# Run all tests
cargo test

# Run tests for specific package
cargo test --package portal-rest
cargo test --package app

# Run with output
cargo test -- --nocapture

# Run specific test
cargo test test_name

Building the JavaScript/TypeScript client

Development Build

cd crates/portal-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 portal-cli --release

# Run it
./target/release/portal-cli --help

# Install globally
cargo install --path crates/portal-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 portal-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 portal-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 portal-rest --release

# Build static binary (Linux only)
cargo build --package portal-rest --release --target x86_64-unknown-linux-musl

macOS

# Build for current architecture
cargo build --package portal-rest --release

# Build universal binary (both Intel and Apple Silicon)
cargo build --package portal-rest --release --target x86_64-apple-darwin
cargo build --package portal-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 portal-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/portal-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:

Installing the SDK

Install and set up the Portal SDK in your project.

Installation

JavaScript

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 endpoint and auth token: From a hosted Portal or from running Portal yourself (e.g. Docker)

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
Java

Using Gradle

  1. Add the Jitpack repository to your build.gradle:
repositories {
    maven { url 'https://jitpack.io' }
}
  1. Add the dependency:
dependencies {
    implementation 'com.github.PortalTechnologiesInc:java-sdk:0.1.0'
}

Using Maven

  1. Add the repository to your pom.xml:
<repository>
    <id>jitpack.io</id>
    <url>https://jitpack.io</url>
</repository>
  1. Add the dependency:
<dependency>
    <groupId>com.github.PortalTechnologiesInc</groupId>
    <artifactId>java-sdk</artifactId>
    <version>0.1.0</version>
</dependency>

Requirements

  • Java: 17 or higher
  • Portal endpoint and auth token: From a hosted Portal or from running Portal yourself (e.g. Docker)

Import and setup

JavaScript

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';
Java

Create a PortalSDK with your health and WebSocket endpoints, then connect with your auth token:

var portalSDK = new PortalSDK(healthEndpoint, wsEndpoint);
portalSDK.connect(authToken);

Use sendCommand(request, callback) to send commands; request and response types are in the SDK package.

Browser Support

JavaScript

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.

Java

The Java SDK targets the JVM (Java 17+). For Kotlin examples, see portal-demo.

Framework Integration

JavaScript

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');
});
Java

Wire PortalSDK as a bean (e.g. in Spring) and pass health URL, WebSocket URL, and auth token from configuration or environment variables.

Environment Variables

JavaScript

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 || '');
Java

Read health URL, WebSocket URL, and auth token from environment or config and pass them to PortalSDK and connect().

Package Information

Exports

The package exports the following:

  • PortalSDK - Main client class
  • Currency - Currency enum
  • Timestamp - Timestamp utility class
  • All type definitions (TypeScript)

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

JavaScript

“Cannot find module ‘portal-sdk’”

# Clear node_modules and reinstall
rm -rf node_modules package-lock.json
npm install

TypeScript / module errors

# Ensure TypeScript is installed
npm install --save-dev typescript

# Check your tsconfig.json includes the right settings

Connection Issues

# Verify Portal is running (if self-hosting)
curl http://localhost:3000/health

# Check endpoint URL (ws:// for local, wss:// for hosted)

Module Resolution Errors

If using ES modules, ensure your package.json has:

{
  "type": "module"
}

Or use .mjs file extension:

mv app.js app.mjs
Java
  • Ensure Jitpack is in repositories and the dependency version is correct.
  • Verify health and WebSocket URLs and auth token; use curl on the health endpoint.
  • For issues, see the Java SDK repository.

Next Steps


Ready to start coding? Head to Basic Usage

Basic Usage

Learn the fundamentals of using the Portal SDK.

Quick Example

JavaScript

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);
Java
var portalSDK = new PortalSDK(healthEndpoint, wsEndpoint);
portalSDK.connect(authToken);

portalSDK.sendCommand(new CalculateNextOccurrenceRequest("weekly", System.currentTimeMillis() / 1000), (res, err) -> {
    if (err != null) {
        logger.error("error: {}", err);
        return;
    }
    logger.info("next occurrence: {}", res.next_occurrence());
});

Core Concepts

JavaScript

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): Portal endpoint URL (e.g. ws://localhost:3000/ws or your hosted URL)
  • 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 Portal using your auth token:

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.

Java

1. Client Initialization

var portalSDK = new PortalSDK(healthEndpoint, wsEndpoint);

2. Connection and authentication

portalSDK.connect(authToken);

connect establishes the WebSocket and authenticates with the given token.

3. Sending commands

Use sendCommand(request, callback) for all server commands. Key request classes include AuthRequest, KeyHandshakeUrlRequest, RequestSinglePaymentRequest, MintCashuRequest; see the API Reference for the full list.

Important: You must call connect(authToken) before sending commands.

Basic Workflows

JavaScript

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');
}
Java

Use KeyHandshakeUrlRequest for auth URLs, RequestSinglePaymentRequest for payments, and the appropriate request classes for profiles. Instantiate a request and pass it to portalSDK.sendCommand(request, (response, err) -> { ... }).

Working with Types

JavaScript

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);
Java

Request/response and notification types are in the SDK. Use the request classes (e.g. CalculateNextOccurrenceRequest) and handle responses in the sendCommand callback. Main types: PortalRequest, PortalResponse, PortalNotification.

Error Handling

JavaScript

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');
}
Java

Check the err parameter in each sendCommand callback; handle connection and auth failures before sending commands. See Error Handling.

Best Practices

JavaScript

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);
  }
});
Java

Reuse one PortalSDK instance, call connect(authToken) once, and use sendCommand for all operations. See portal-demo for a full Kotlin example.

Complete Example

JavaScript

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);
Java
var portalSDK = new PortalSDK(healthEndpoint, wsEndpoint);
portalSDK.connect(authToken);

portalSDK.sendCommand(new CalculateNextOccurrenceRequest("weekly", System.currentTimeMillis() / 1000), (res, err) -> {
    if (err != null) {
        logger.error("error: {}", err);
        return;
    }
    logger.info("next occurrence: {}", res.next_occurrence());
});

See portal-demo for a complete Kotlin example.


Next Steps:

SDK Configuration

Configure the Portal SDK for your specific needs.

Basic Configuration

JavaScript
const client = new PortalSDK({
  serverUrl: 'ws://localhost:3000/ws',
  connectTimeout: 10000
});
Java

Create PortalSDK with health and WebSocket endpoints: new PortalSDK(healthEndpoint, wsEndpoint). Call connect(authToken) to connect and authenticate.

Configuration Options

JavaScript

serverUrl (required)

The Portal endpoint URL (where Portal is running—e.g. your hosted URL or local Docker).

// Local development (e.g. Docker)
serverUrl: 'ws://localhost:3000/ws'

// Production (hosted Portal)
serverUrl: 'wss://portal.yourdomain.com/ws'

connectTimeout (optional)

Connection timeout in milliseconds. Default: 10000 (10 seconds)

connectTimeout: 5000  // 5 seconds

debug (optional)

When true, the SDK logs requests and responses to the console (via console.debug). Useful for development. Default: false.

const client = new PortalSDK({
  serverUrl: 'ws://localhost:3000/ws',
  debug: true
});
Java

Pass health URL, WebSocket URL, and (for connect) auth token from environment or config.

Environment-Based Configuration

JavaScript
const config = {
  serverUrl: process.env.PORTAL_WS_URL || 'ws://localhost:3000/ws',
  connectTimeout: parseInt(process.env.PORTAL_TIMEOUT || '10000')
};

const client = new PortalSDK(config);
Java

Handle responses and errors in the sendCommand(request, (response, err) -> { ... }) callback.

Event Configuration

JavaScript

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();
Java

Handle responses and errors in sendCommand(request, (response, err) -> { ... }).


Next: Error Handling

Error Handling

Handle errors gracefully in your Portal integration.

PortalSDKError and error codes

JavaScript

The SDK throws PortalSDKError with a code property so you can handle cases in code. Always check err instanceof PortalSDKError and switch on err.code:

import { PortalSDKError } from 'portal-sdk';

try {
  await client.connect();
  await client.authenticate(token);
} catch (err) {
  if (err instanceof PortalSDKError) {
    switch (err.code) {
      case 'AUTH_FAILED':
        // Invalid or expired token
        break;
      case 'CONNECTION_TIMEOUT':
      case 'CONNECTION_CLOSED':
        // Connection issues — is Portal running? Check serverUrl.
        break;
      case 'NOT_CONNECTED':
        // Call connect() before other methods
        break;
      case 'UNEXPECTED_RESPONSE':
      case 'SERVER_ERROR':
      case 'PARSE_ERROR':
        // Protocol or server error; err.message and optional err.details
        break;
      default:
        break;
    }
  }
  throw err;
}

Error codes

CodeWhen
NOT_CONNECTEDA method was called before connect() or after disconnect.
CONNECTION_TIMEOUTConnection did not open within connectTimeout.
CONNECTION_CLOSEDSocket closed unexpectedly.
AUTH_FAILEDInvalid or rejected auth token.
UNEXPECTED_RESPONSEServer sent an unexpected response type.
SERVER_ERRORServer returned an error (message in err.message).
PARSE_ERRORFailed to parse a message; optional err.details.

Background / connection events

Listen for connection and background errors via on:

client.on({
  onConnected: () => console.log('Connected'),
  onDisconnected: () => console.log('Disconnected'),
  onError: (e) => console.error('Portal error:', e),
});
Java

Check the err parameter in each sendCommand callback; handle connection and auth failures before sending commands.

Common error patterns

JavaScript

Connection errors

try {
  await client.connect();
} catch (error) {
  if (error instanceof PortalSDKError && error.code === 'CONNECTION_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 instanceof PortalSDKError && error.code === 'AUTH_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
  }
});
Java

Handle err != null in callbacks; retry connect(authToken) on failure.

Error Recovery

JavaScript

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);
  }
});
Java

Check err in every sendCommand callback; implement retry for connect; log errors with context.

Best Practices

JavaScript
  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
Java

Always check the callback err parameter; use environment or config for URLs and token.


Next: Authentication Guide

SDK API reference

Concise reference for the main PortalSDK methods. For examples and workflows, see Basic Usage and the Guides.

Lifecycle & Auth

JavaScript
MethodDescription
connect(): Promise<void>Connect to Portal. Call once before other methods.
disconnect(): voidClose connection and clear state.
authenticate(token: string): Promise<void>Authenticate with your auth token (required after connect).

Auth and users

MethodDescription
newKeyHandshakeUrl(onKeyHandshake, staticToken?, noRequest?): Promise<string>Get URL for user key handshake; callback runs when user completes handshake.
authenticateKey(mainKey, subkeys?): Promise<AuthResponseData>Authenticate a user key (NIP-46 style).
Java
  • PortalSDK(healthEndpoint, wsEndpoint) — Create client.
  • connect(authToken) — Connect and authenticate.
  • sendCommand(request, callback) — Send any command. Request classes: AuthRequest, KeyHandshakeUrlRequest, RequestSinglePaymentRequest, MintCashuRequest, CalculateNextOccurrenceRequest, and others in the SDK.

Payments

JavaScript
MethodDescription
requestSinglePayment(mainKey, subkeys, paymentRequest, onStatusChange): Promise<void>Request a one-time Lightning payment.
requestRecurringPayment(mainKey, subkeys, paymentRequest): Promise<RecurringPaymentResponseContent>Request a recurring (subscription) payment.
requestInvoicePayment(mainKey, subkeys, paymentRequest, onStatusChange): Promise<void>Pay an invoice on behalf of a user.
requestInvoice(recipientKey, subkeys, content): Promise<InvoiceResponseContent>Request an invoice.
closeRecurringPayment(mainKey, subkeys, subscriptionId): Promise<string>Close a recurring payment subscription.
listenClosedRecurringPayment(onClosed): Promise<() => void>Listen for closed recurring payments; returns unsubscribe function.
Java

Use RequestSinglePaymentRequest, MintCashuRequest, and other request classes with sendCommand. See the Java SDK repository for the full list.

Profiles and identity

JavaScript
MethodDescription
fetchProfile(mainKey)Fetch a user’s Nostr profile (Promise<Profile | null>).
setProfile(profile): Promise<void>Set or update a profile.
fetchNip05Profile(nip05): Promise<Nip05Profile>Resolve a NIP-05 identifier.
Java

Use the appropriate request classes with sendCommand for profile and identity operations.

JWT

JavaScript
MethodDescription
issueJwt(target_key, duration_hours): Promise<string>Issue a JWT for the given key.
verifyJwt(public_key, token): Promise<{ target_key: string }>Verify a JWT and return claims.
Java

Use the JWT-related request classes with sendCommand.

Relays and Cashu

JavaScript
MethodDescription
addRelay(relay): Promise<string>Add a relay.
removeRelay(relay): Promise<string>Remove a relay.
requestCashu(...)Request Cashu tokens. See Cashu guide.
sendCashuDirect(...)Send Cashu tokens.
mintCashu(...)Mint Cashu tokens.
burnCashu(...)Burn Cashu tokens.
calculateNextOccurrence(calendar, from)Compute next occurrence for a recurrence calendar (Promise<Timestamp | null>).
Java

Use KeyHandshakeUrlRequest, RequestSinglePaymentRequest, MintCashuRequest, relay and Cashu request classes with sendCommand.

Events

JavaScript
MethodDescription
on(eventType | EventCallbacks, callback?): voidRegister listener, e.g. on('connected', fn) or on({ onConnected, onDisconnected, onError }).
off(eventType, callback): voidRemove a listener.
Java

Responses and notifications are delivered in the sendCommand callback.

Types overview

JavaScript
  • Currency — e.g. Currency.Millisats.
  • TimestampTimestamp.fromDate(date), Timestamp.fromNow(seconds), toDate(), toJSON().
  • Profileid, pubkey, name, display_name, picture, about, nip05.
  • RecurringPaymentRequestContent, SinglePaymentRequestContent, InvoiceRequestContent — See type definitions in the package.
  • AuthResponseData, InvoiceStatus, RecurringPaymentStatus — Response and status types.

Full types are exported from portal-sdk; use your editor’s IntelliSense or the package source.

Java

PortalRequest, PortalResponse, PortalNotification, and request/response classes (e.g. CalculateNextOccurrenceRequest). See the Java SDK repository for the full API.


Next: Error Handling for PortalSDKError and error codes.

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]
mint_max_bat = 50
enabled_mint = true
enabled_melt = true
enabled_swap = false
enabled_restore = false
enabled_check_proof_state = false

[auth.method.Static]
token = "your-secure-static-token"

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]
mint_max_bat = 50
enabled_mint = true
enabled_melt = true
enabled_swap = false
enabled_restore = false
enabled_check_proof_state = false

[auth.method.Static]
token = "your-secure-static-token-here"

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 \
  -e CDK_MINTD_MNEMONIC="<your mnemonic here>" \
  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 \
  -e CDK_MINTD_MNEMONIC="<your mnemonic here>" \
  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
  • -e CDK_MINTD_MNEMONIC="<your mnemonic here>" - Set mnemonic as evironment variable
  • 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 \
  -e CDK_MINTD_MNEMONIC="<your mnemonic here>" \
  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.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: SDK

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 PORTAL__NOSTR__PRIVATE_KEY is set correctly

Payments not working

  • Verify PORTAL__WALLET__NWC__URL is configured (with PORTAL__WALLET__LN_BACKEND=nwc)
  • 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 PORTAL__AUTH__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 PORTAL__NOSTR__PRIVATE_KEY is set correctly
# Test relay connectivity
wscat -c wss://relay.damus.io

# Verify key format (64 hex chars)
echo $PORTAL__NOSTR__PRIVATE_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 (PORTAL__WALLET__NWC__URL)
echo $PORTAL__WALLET__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 PORTAL__NOSTR__RELAYS in .env
PORTAL__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 PORTAL__AUTH__AUTH_TOKEN=$PORTAL__AUTH__AUTH_TOKEN \
  -e PORTAL__NOSTR__PRIVATE_KEY=$PORTAL__NOSTR__PRIVATE_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

JavaScript 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 / module 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/PortalTechnologiesInc/lib.git
cd lib
  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!