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

Portal

Project Homepage | Repository | Become a supporter

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

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

Portal exposes a standard HTTP REST API — you can integrate from any language or platform.

  1. Run the Portal daemon — self-host or develop locally: run getportal/sdk-daemon with Docker (see Quick Start).
  2. Call the REST API — use any HTTP client (curl, Python, Go, Ruby, PHP…), or use the official SDKs for JavaScript/TypeScript and Java.
  3. Auth, payments, tickets — generate auth URLs (users approve with Nostr wallet), request single or recurring Lightning payments, issue Cashu tokens.

Integration options

OptionWhen to use
HTTP / RESTAny language — Python, Go, Ruby, PHP, Rust, etc. No SDK needed.
JavaScript / TypeScript SDKNode.js and browser apps. Handles polling and webhooks automatically.
Java SDKJVM apps. Same capabilities as the JS SDK.

All options talk to the same REST API under the hood. The SDKs just add typed wrappers and auto-polling.

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.
  • REST API — Standard HTTP, OpenAPI spec, any HTTP client.

Getting started

  • Quick Start — Get going in minutes with Docker + HTTP or an SDK.
  • REST API — Use Portal from any language over HTTP.
  • OpenAPI Reference — Full interactive API reference.
  • SDK — Install the JavaScript or Java SDK.
  • Docker — Run the Portal daemon with Docker.
  • Guides — Auth flow, payments, profiles, Cashu, JWT, relays.

Docs overview

SectionFor
Getting StartedQuick Start, Docker, env vars, building from source.
SDK & REST APIREST API, SDK install, usage, config, errors, OpenAPI reference.
GuidesAuth, payments, profiles, Cashu, JWT, relays — with curl, JS, and Java examples.
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

Use Portal from any language over HTTP, or install an SDK for JavaScript or Java.

1. Run the Portal daemon

You need a Nostr private key (hex) and a secret auth token. 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:0.4.0

Check it's running:

curl http://localhost:3000/health
# → OK

2. Install (optional — only if using an SDK)

HTTP

Nothing to install. Set your base URL and token:

export BASE_URL=http://localhost:3000
export AUTH_TOKEN=my-secret-token
JavaScript
npm install portal-sdk
Java

Gradle:

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

Maven:

<repository>
    <id>jitpack.io</id>
    <url>https://jitpack.io</url>
</repository>
<dependency>
    <groupId>com.github.PortalTechnologiesInc</groupId>
    <artifactId>java-sdk</artifactId>
    <version>0.4.0</version>
</dependency>

See SDK Installation.

3. First request — key handshake URL

Generate a URL for a user to authenticate with their Nostr wallet:

HTTP
# Start the handshake — get a URL to show the user
curl -s -X POST $BASE_URL/key-handshake \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
# → { "stream_id": "abc123", "url": "nostr+walletconnect://..." }

# Show the URL to the user (QR code, link, etc.)
# Then poll for the user's public key:
curl -s "$BASE_URL/events/abc123?after=0" \
  -H "Authorization: Bearer $AUTH_TOKEN"
# → { "events": [{ "index": 0, "data": { "main_key": "USER_PUBKEY_HEX" } }] }

See REST API for the full async polling pattern.

JavaScript
import { PortalClient } from 'portal-sdk';

const client = new PortalClient({
  baseUrl: 'http://localhost:3000',
  authToken: 'my-secret-token'
});

const { url, stream } = await client.newKeyHandshakeUrl();
console.log('Share with user:', url);

// Wait for the user to scan/click the URL
const result = await client.poll(stream);
console.log('User key:', result.main_key);
Java
import cc.getportal.PortalClient;
import cc.getportal.PortalClientConfig;

PortalClient client = new PortalClient(
    PortalClientConfig.create("http://localhost:3000", "my-secret-token")
);

var operation = client.newKeyHandshakeUrl();
System.out.println("Share with user: " + operation.url());

// Poll until the user completes the handshake
var result = client.pollUntilComplete(operation);
System.out.println("User key: " + result.main_key());

See API Reference and Authentication guide.

Common issues

IssueFix
Connection refusedPortal not running or wrong port. Check docker ps.
401 UnauthorizedToken must match PORTAL__AUTH__AUTH_TOKEN.
Invalid Nostr keyUse hex; convert nsec with e.g. nak decode nsec ....

Docker Deployment

Run with pre-built image

docker run -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:0.3.0

Tip: pin a specific version in production (e.g. 0.3.0) rather than :latest to avoid unexpected updates. The image is multi-arch (amd64 + arm64) — Docker pulls the right variant automatically. See Versioning & Compatibility.

Check: curl http://localhost:3000/health, curl http://localhost:3000/version. WebSocket API: ws://localhost:3000/ws (auth required).

Docker Compose

docker-compose.yml:

services:
  portal:
    image: getportal/sdk-daemon:0.3.0
    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

.env: set PORTAL__AUTH__AUTH_TOKEN, PORTAL__NOSTR__PRIVATE_KEY; optionally PORTAL__WALLET__LN_BACKEND=nwc, PORTAL__WALLET__NWC__URL, PORTAL__NOSTR__RELAYS. Then docker compose up -d.

Env vars

VariableDescription
PORTAL__AUTH__AUTH_TOKENAPI auth token (required).
PORTAL__NOSTR__PRIVATE_KEYNostr private key hex (required).
PORTAL__WALLET__LN_BACKENDnone, nwc, or breez.
PORTAL__WALLET__NWC__URLNWC URL when ln_backend=nwc.
PORTAL__WALLET__BREEZ__API_KEYBreez API key when ln_backend=breez.
PORTAL__WALLET__BREEZ__MNEMONICBreez mnemonic when ln_backend=breez.
PORTAL__NOSTR__RELAYSComma-separated relay URLs.
PORTAL__NOSTR__SUBKEY_PROOFProof for Nostr subkey delegation (optional).

Full list: Environment variables.

Build image from repo

git clone https://github.com/PortalTechnologiesInc/lib.git
cd lib
docker build -t portal-rest:latest .
docker run -d -p 3000:3000 -e PORTAL__AUTH__AUTH_TOKEN=... -e PORTAL__NOSTR__PRIVATE_KEY=... portal-rest:latest

Or with Nix: nix build .#rest-docker then docker load < result.

Use HTTPS and a reverse proxy in production; don’t commit secrets.


Environment Variables

Portal reads ~/.portal-rest/config.toml and overrides any value with environment variables using the format PORTAL__<SECTION>__<KEY> (double underscore separator).

Config and env

  • Config file: ~/.portal-rest/config.toml. Full example: crates/portal-rest/example.config.toml.
  • Override: PORTAL__<SECTION>__<KEY>=value. Example: PORTAL__AUTH__AUTH_TOKEN=secret.
  • Data: wallet data (when ln_backend=breez) is stored under ~/.portal-rest/breez.

All settings

Core

Config keyEnv varRequiredDefaultDescription
info.listen_portPORTAL__INFO__LISTEN_PORTNo3000HTTP port.
auth.auth_tokenPORTAL__AUTH__AUTH_TOKENYesBearer token for API access.
nostr.private_keyPORTAL__NOSTR__PRIVATE_KEYYesNostr private key (64 hex chars).
nostr.relaysPORTAL__NOSTR__RELAYSNowss://relay.nostr.net, wss://relay.damus.io, wss://relay.getportal.ccComma-separated relay URLs.
nostr.subkey_proofPORTAL__NOSTR__SUBKEY_PROOFNoSubkey delegation proof.

Database

Config keyEnv varRequiredDefaultDescription
database.pathPORTAL__DATABASE__PATHNoportal-rest.dbSQLite file path. Relative paths resolve under ~/.portal-rest/.

Wallet

Config keyEnv varRequiredDefaultDescription
wallet.ln_backendPORTAL__WALLET__LN_BACKENDNononenone, nwc, or breez.
wallet.nwc.urlPORTAL__WALLET__NWC__URLIf ln_backend=nwcNWC connection URL.
wallet.breez.api_keyPORTAL__WALLET__BREEZ__API_KEYIf ln_backend=breezBreez API key.
wallet.breez.mnemonicPORTAL__WALLET__BREEZ__MNEMONICIf ln_backend=breezBreez wallet mnemonic.

Webhooks

Webhooks are an alternative to polling — the daemon will POST events to your endpoint as they arrive.

Config keyEnv varRequiredDefaultDescription
webhook.urlPORTAL__WEBHOOK__URLNoURL to receive webhook events.
webhook.secretPORTAL__WEBHOOK__SECRETNoShared secret for HMAC-SHA256 signatures (X-Portal-Signature header).

When webhook.secret is set, each request includes an X-Portal-Signature header with the HMAC-SHA256 signature of the body. Verify it to authenticate incoming webhooks:

import hmac, hashlib

def verify(secret: str, body: bytes, signature: str) -> bool:
    expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Profile

Publish your service's Nostr profile at startup. All fields are optional — omit the section to skip.

Config keyEnv varRequiredDefaultDescription
profile.namePORTAL__PROFILE__NAMENoUsername (no spaces).
profile.display_namePORTAL__PROFILE__DISPLAY_NAMENoDisplay name.
profile.picturePORTAL__PROFILE__PICTURENoAvatar URL.
profile.nip05PORTAL__PROFILE__NIP05NoNIP-05 verified identifier.

Minimal setup

PORTAL__AUTH__AUTH_TOKEN=dev-token \
PORTAL__NOSTR__PRIVATE_KEY=your-64-char-hex-key \
portal-rest

Generate a token: openssl rand -hex 32
Convert nsec to hex: nak decode nsec1...

With Docker

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:0.4.0

Or use a .env file: docker run --env-file .env .... Don't commit .env.

Troubleshooting

ProblemFix
401 UnauthorizedToken must match PORTAL__AUTH__AUTH_TOKEN.
Invalid Nostr keyMust be 64 hex chars. Convert nsec: nak decode nsec1....
Relays not connectingUse wss:// URLs; e.g. wss://relay.damus.io.
DB path errorUse an absolute path or ensure ~/.portal-rest/ exists.

Building from Source

Prerequisites: Rust 1.70+ (rustup), Git. Optional: Nix for reproducible builds.

Clone and build

git clone https://github.com/PortalTechnologiesInc/lib.git
cd lib

REST API (portal-rest):

cargo build --package portal-rest --release
./target/release/rest

Nix:

nix build .#rest
./result/bin/rest

Docker image: nix build .#rest-docker then docker load < result.

Other targets

  • CLI: cargo build --package portal-cli --release → ./target/release/portal-cli
  • TS client: cd crates/portal-rest/clients/ts && npm install && npm run build

Run with env

PORTAL__AUTH__AUTH_TOKEN=dev-token \
PORTAL__NOSTR__PRIVATE_KEY=your-key-hex \
cargo run --package portal-rest --release

Config can come from ~/.portal-rest/config.toml or env; see Environment variables.

Cross-build (Nix)

nix build .#rest --system x86_64-linux
nix build .#rest --system aarch64-darwin

Troubleshooting

  • Missing OpenSSL: Install libssl-dev (Debian/Ubuntu) or openssl (macOS); set PKG_CONFIG_PATH if needed.
  • Linker: Linux: sudo apt-get install build-essential; macOS: xcode-select --install.
  • Nix: nix flake update and nix build -L .#rest for verbose output.

Versioning & Compatibility

Portal follows a simple versioning policy designed to make compatibility obvious.

Version scheme

All Portal components use semantic versioning (major.minor.patch):

ComponentPackage
portal-rest (SDK Daemon)Docker: getportal/sdk-daemon
TypeScript SDKnpm: portal-sdk
Java SDKJitPack: com.github.PortalTechnologiesInc:java-sdk

Compatibility rule

major.minor must match between the SDK and the SDK Daemon. The patch version is independent.

In other words:

  • SDK 0.3.0 ↔ SDK Daemon 0.3.0
  • SDK 0.3.4 ↔ SDK Daemon 0.3.1 ✅ (patch is irrelevant)
  • SDK 0.3.x ↔ SDK Daemon 0.4.x ❌ (minor mismatch)

Patch releases contain bug fixes and non-breaking improvements within the same major.minor. You can update the SDK or the Daemon independently as long as major.minor stays the same.

Upgrading

When a new major.minor version is released:

  1. Update your SDK dependency to the matching version.
  2. Update the Docker image tag to the matching version.
  3. Check the CHANGELOG for breaking changes.

Example — upgrading to 0.4.0:

# Docker
docker pull getportal/sdk-daemon:0.4.0
# npm
npm install portal-sdk@0.4.0
// Gradle
implementation 'com.github.PortalTechnologiesInc:java-sdk:0.4.0'

Current versions

ComponentVersion
SDK Daemon (getportal/sdk-daemon)0.3.0
TypeScript SDK (portal-sdk)0.3.0
Java SDK0.3.0

Installation

Any language: REST API · JavaScript/TypeScript: npm · Java: GitHub

Requirements

HTTP

No SDK needed. Any HTTP client works: curl, Python, Go, Ruby, PHP, etc.

  • Portal endpoint and auth token
  • That's it.
JavaScript
  • Node.js 18+
  • TypeScript 4.5+ (optional)
  • Portal endpoint and auth token
Java
  • Java 17+
  • Portal endpoint and auth token

Install

HTTP

Nothing to install. Set your base URL and token:

export BASE_URL=http://localhost:3000
export AUTH_TOKEN=your-secret-token
JavaScript
npm install portal-sdk

Or yarn add portal-sdk / pnpm add portal-sdk.

Java

Gradle (build.gradle or build.gradle.kts):

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

Maven (pom.xml):

<repository>
    <id>jitpack.io</id>
    <url>https://jitpack.io</url>
</repository>
<dependency>
    <groupId>com.github.PortalTechnologiesInc</groupId>
    <artifactId>java-sdk</artifactId>
    <version>0.4.0</version>
</dependency>

Use

HTTP
# Health check
curl -s $BASE_URL/health

# All requests use Bearer auth
curl -s $BASE_URL/version \
  -H "Authorization: Bearer $AUTH_TOKEN"

See REST API for the full reference and async polling pattern.

JavaScript
import { PortalClient } from 'portal-sdk';

const client = new PortalClient({
  baseUrl: 'http://localhost:3000',
  authToken: 'your-auth-token'
});
Java
import cc.getportal.PortalClient;
import cc.getportal.PortalClientConfig;

PortalClient client = new PortalClient(
    PortalClientConfig.create("http://localhost:3000", "your-auth-token")
);

See API Reference and the Java SDK.


Compatibility: the SDK major.minor version must match the SDK Daemon (getportal/sdk-daemon) major.minor. Patch versions are independent. See Versioning & Compatibility.


Basic Usage

Quick example

HTTP
export BASE_URL=http://localhost:3000
export AUTH_TOKEN=your-auth-token

# Get a key handshake URL
curl -s -X POST $BASE_URL/key-handshake \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
# → { "stream_id": "abc123", "url": "nostr+walletconnect://..." }

# Poll for the user's public key
curl -s "$BASE_URL/events/abc123?after=0" \
  -H "Authorization: Bearer $AUTH_TOKEN"

See REST API for the full async flow.

JavaScript
import { PortalClient } from 'portal-sdk';

const client = new PortalClient({
  baseUrl: 'http://localhost:3000',
  authToken: 'your-auth-token'
});

const { url, stream } = await client.newKeyHandshakeUrl();
console.log('User key:', (await client.poll(stream)).main_key);
Java
import cc.getportal.PortalClient;
import cc.getportal.PortalClientConfig;

PortalClient client = new PortalClient(
    PortalClientConfig.create("http://localhost:3000", "your-auth-token")
);

var operation = client.newKeyHandshakeUrl();
System.out.println("URL: " + operation.url());
var result = client.pollUntilComplete(operation);
System.out.println("mainKey: " + result.main_key());

See API Reference and the Java SDK.

What to call


Configuration

Client options

HTTP

No client to configure — just set your base URL and auth token in each request:

export BASE_URL=http://localhost:3000
export AUTH_TOKEN=your-secret-token

# All requests:
curl -s $BASE_URL/endpoint \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json"

See Environment Variables for daemon configuration.

JavaScript

Pass options to PortalClient:

OptionRequiredDescription
baseUrlYesHTTP base URL (e.g. http://localhost:3000)
authTokenYesBearer token matching PORTAL__AUTH__AUTH_TOKEN
autoPollingIntervalMsNoEnable auto-polling; interval in ms (e.g. 2000)
import { PortalClient } from 'portal-sdk';

const client = new PortalClient({
  baseUrl: 'http://localhost:3000',
  authToken: 'your-auth-token',
  autoPollingIntervalMs: 2000  // optional: poll every 2s automatically
});

// Stop auto-polling when done
client.destroy();
Java

Use PortalClientConfig builder:

import cc.getportal.PortalClient;
import cc.getportal.PortalClientConfig;

// Manual polling
PortalClient client = new PortalClient(
    PortalClientConfig.create("http://localhost:3000", "your-auth-token")
);

// Auto-polling every 2 seconds
PortalClient client = new PortalClient(
    PortalClientConfig.create("http://localhost:3000", "your-auth-token")
                      .autoPolling(2000)
);

// Webhook mode
PortalClient client = new PortalClient(
    PortalClientConfig.create("http://localhost:3000", "your-auth-token")
                      .webhookSecret("your-webhook-secret")
);

Next: Error Handling

Error Handling

HTTP

The API returns standard HTTP status codes:

StatusMeaning
200Success
400Bad request (invalid parameters)
401Unauthorized (wrong or missing auth token)
404Not found (stream_id expired or unknown)
409Conflict (operation already in progress)
500Internal server error

Error responses include a JSON body:

{ "error": "Invalid or unsupported version." }

For async operations, terminal error events arrive in the polling stream:

curl -s "$BASE_URL/events/$STREAM?after=0" -H "Authorization: Bearer $AUTH_TOKEN"
# → { "events": [{ "data": { "status": "error", "reason": "user_rejected" } }] }
JavaScript

The SDK throws PortalSDKError with a code property:

import { PortalSDKError } from 'portal-sdk';

try {
  await client.connect();
  await client.authenticate(token);
} catch (err) {
  if (err instanceof PortalSDKError) {
    // err.code: AUTH_FAILED, CONNECTION_TIMEOUT, CONNECTION_CLOSED, NOT_CONNECTED, etc.
  }
  throw err;
}

Error codes

CodeWhen
NOT_CONNECTEDMethod 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 unexpected response type.
SERVER_ERRORServer returned an error (err.message).
PARSE_ERRORFailed to parse a message; optional err.details.
Java

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

sdk.sendCommand(someRequest, (response, err) -> {
    if (err != null) {
        System.err.println("Command failed: " + err);
        return;
    }
    // use response
});

Next: Authentication Guide

SDK API reference

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

SDK references: JavaScript/TypeScript SDK (npm) · Java SDK (GitHub)

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
Class / methodDescription
PortalSDK(wsEndpoint)Create client with WebSocket URL.
connect()Establish WebSocket connection (blocking).
authenticate(authToken)Authenticate with your token (sends AuthRequest internally).
sendCommand(request, (response, err) -> { ... })Send any command. Request classes below.

Auth and users: KeyHandshakeUrlRequest(notificationCallback) or (staticToken, noRequest, notificationCallback); AuthenticateKeyRequest(mainKey, subkeys). Response: KeyHandshakeUrlResponse.url(), AuthenticateKeyResponse.

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
Request classDescription
RequestSinglePaymentRequest(mainKey, subkeys, paymentContent, statusNotificationCallback)One-time Lightning payment.
RequestRecurringPaymentRequest(mainKey, subkeys, paymentContent)Recurring (subscription) payment.
RequestInvoicePaymentRequest(...)Pay an invoice.
RequestInvoiceRequest(...)Request an invoice.
CloseRecurringPaymentRequest(mainKey, subkeys, subscriptionId)Close a subscription.
ListenClosedRecurringPaymentRequest(onClosedCallback)Listen for user cancellations.

Content types: SinglePaymentRequestContent, RecurringPaymentRequestContent (with RecurrenceInfo). See Java SDK.

Profiles and identity

JavaScript
MethodDescription
fetchProfile(mainKey)Fetch a user's Nostr profile (Promise<Profile
setProfile(profile): Promise<void>Set or update a profile.
fetchNip05Profile(nip05): Promise<Nip05Profile>Resolve a NIP-05 identifier.
Java
Request classDescription
FetchProfileRequest(mainKey)Fetch Nostr profile. Response: FetchProfileResponse.profile().
SetProfileRequest(profile)Set or update a profile. Profile model: name, displayName, picture, nip05, etc.
FetchNip05ProfileRequest(nip05)Resolve NIP-05 identifier.

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
Request classDescription
IssueJwtRequest(targetKey, durationHours)Issue a JWT. Response: IssueJwtResponse.token().
VerifyJwtRequest(publicKey, token)Verify a JWT. Response: VerifyJwtResponse (claims).

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
Java
Request classDescription
AddRelayRequest(relayUrl)Add a relay.
RemoveRelayRequest(relayUrl)Remove a relay.
RequestCashuRequest(mintUrl, unit, amount, recipientKey, subkeys)Request Cashu tokens from user.
MintCashuRequest(mintUrl, staticToken?, unit, amount, description?)Mint Cashu tokens.
BurnCashuRequest(mintUrl, staticToken?, unit, token)Burn (redeem) a token.
SendCashuDirectRequest(mainKey, subkeys, token)Send Cashu token to user.
CalculateNextOccurrenceRequest(calendar, fromTimestamp)Next occurrence for recurrence.

See Cashu guide and Java SDK.

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.
  • Timestamp — Timestamp.fromDate(date), Timestamp.fromNow(seconds), toDate(), toJSON().
  • Profile — id, 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
TypeDescription
PortalRequest, PortalResponse, PortalNotificationBase types for sendCommand.
Currencye.g. Currency.MILLISATS.
SinglePaymentRequestContent(description, amount, currency, ...)Single payment params.
RecurringPaymentRequestContent(..., RecurrenceInfo, expiresAt)Recurring payment params.
RecurrenceInfo(..., calendar, ..., firstPaymentDue)Calendar: "weekly", "monthly", etc.
Profile(name, displayName, picture, nip05)Nostr profile model.

All request/response/notification classes in cc.getportal.command.request, cc.getportal.command.response, cc.getportal.command.notification, cc.getportal.model. See Java SDK.


Next: Error Handling for PortalSDKError and error codes.

REST API

The SDK Daemon exposes a standard HTTP REST API. You don't need the JavaScript or Java SDK — any HTTP client works: curl, Python requests, Go's net/http, Ruby's Faraday, etc.

Base URL & Auth

BASE_URL=http://localhost:3000
AUTH_TOKEN=your-secret-token

All requests require:

Authorization: Bearer $AUTH_TOKEN
Content-Type: application/json

Async operations

Most Portal operations (payments, auth, key handshake) are async — the user must approve in their wallet before a result is available. The pattern is always:

  1. Start the operation → receive a stream_id
  2. Poll for events until the operation completes
# Step 1: start operation (example: authenticate a key)
STREAM=$(curl -s -X POST $BASE_URL/authenticate-key \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"main_key": "...", "subkeys": []}' \
  | jq -r .stream_id)

# Step 2: poll until done
while true; do
  EVENTS=$(curl -s "$BASE_URL/events/$STREAM?after=0" \
    -H "Authorization: Bearer $AUTH_TOKEN")
  echo $EVENTS | jq .
  # check if terminal event received, then break
  sleep 1
done

Event polling

GET /events/{stream_id}?after={index}

Returns events published since after. Start at after=0, then pass the last received index + 1 on subsequent calls.

Response:

{
  "events": [
    { "index": 0, "type": "StatusUpdate", "timestamp": 1234567890, "data": { ... } }
  ]
}

Terminal events (no more polling needed): status paid, approved, declined, user_rejected, timeout, error.

Webhooks (alternative to polling)

Instead of polling, configure a webhook URL and the daemon will POST events to your endpoint as they arrive. See Configuration for PORTAL__WEBHOOK_URL and PORTAL__WEBHOOK_SECRET.

Key endpoints

EndpointMethodDescription
/healthGETHealth check
/versionGETDaemon version
/key-handshakePOSTGenerate auth URL for user
/authenticate-keyPOSTAuthenticate a key
/payments/singlePOSTRequest single payment
/payments/recurringPOSTRequest recurring payment
/payments/recurring/closePOSTClose recurring subscription
/invoices/requestPOSTRequest an invoice
/invoices/payPOSTPay a BOLT11 invoice
/cashu/requestPOSTRequest Cashu tokens
/profile/{main_key}GETFetch user profile
/events/{stream_id}GETPoll async operation events

Full schema and request/response types: API Reference.

Examples

Authentication flow

# 1. Get a key handshake URL (show this to your user as QR or link)
curl -s -X POST $BASE_URL/key-handshake \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

# Response: { "stream_id": "abc123", "url": "nostr+walletconnect://..." }
# → Show the URL to the user. Poll the stream for the user's key.

Single payment

# 1. Request payment (amount in millisats)
curl -s -X POST $BASE_URL/payments/single \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "main_key": "USER_PUBKEY_HEX",
    "subkeys": [],
    "description": "Premium - 1 month",
    "amount": 10000,
    "currency": "millisats"
  }'

# Response: { "stream_id": "xyz789" }

# 2. Poll for result
curl -s "$BASE_URL/events/xyz789?after=0" \
  -H "Authorization: Bearer $AUTH_TOKEN"

# Response: { "events": [{ "index": 0, "type": "StatusUpdate", "data": { "status": "paid", "preimage": "..." } }] }

Profile lookup

curl -s $BASE_URL/profile/USER_PUBKEY_HEX \
  -H "Authorization: Bearer $AUTH_TOKEN"

API Reference (OpenAPI)

Full interactive reference for the Portal REST API, generated from the openapi.yaml spec.

If the interactive viewer doesn't load, open the raw spec directly: openapi.yaml on GitHub or paste the raw URL into Redocly / Swagger Editor.

Authentication Flow

Portal uses Nostr key pairs: users prove identity by signing challenges. Your app gets an auth URL; the user opens it in a Nostr wallet and approves; you receive the key handshake and verify.

API

  1. Generate auth URL: newKeyHandshakeUrl(onKeyHandshake, staticToken?, noRequest?) — callback runs when the user completes the handshake.
  2. Authenticate the key: authenticateKey(mainKey, subkeys?) — returns AuthResponseData with status (approved / declined), optional session_token, reason.
JavaScript
const authUrl = await client.newKeyHandshakeUrl(async (mainKey, preferredRelays) => {
  const authResponse = await client.authenticateKey(mainKey);
  if (authResponse.status.status === 'approved') {
    // session_token in authResponse.status.session_token
  }
});
// Share authUrl (QR, link, etc.) with user
Java
import cc.getportal.command.request.KeyHandshakeUrlRequest;
import cc.getportal.command.request.AuthenticateKeyRequest;
import cc.getportal.command.response.KeyHandshakeUrlResponse;
import cc.getportal.command.response.AuthenticateKeyResponse;
import java.util.List;

// 1) Get handshake URL (notification gives mainKey when user completes)
sdk.sendCommand(
    new KeyHandshakeUrlRequest((n) ->
        System.out.println("mainKey: " + n.main_key())),
    (res, err) -> {
        if (err != null) return;
        System.out.println("URL: " + res.url());
    }
);

// 2) Authenticate with key (after user completed handshake)
sdk.sendCommand(
    new AuthenticateKeyRequest("user-pubkey-hex", List.of()),
    (res, err) -> {
        if (err != null) { System.err.println(err); return; }
        System.out.println("authenticated");
    }
);
HTTP
# 1. Get a key handshake URL
curl -s -X POST $BASE_URL/key-handshake \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
# → { "stream_id": "abc123", "url": "nostr+walletconnect://..." }
# Show the URL to the user (QR code, link, etc.)
# Poll the stream to receive the user's public key when they complete the handshake.

# 2. Authenticate the key
curl -s -X POST $BASE_URL/authenticate-key \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"main_key": "USER_PUBKEY_HEX", "subkeys": []}'
# → { "stream_id": "def456" }

# 3. Poll for result
curl -s "$BASE_URL/events/def456?after=0" \
  -H "Authorization: Bearer $AUTH_TOKEN"
# → { "events": [{ "index": 0, "type": "StatusUpdate", "data": { "status": "approved", "session_token": "..." } }] }

See REST API for the full polling flow.

  • Subkeys: Pass optional subkeys to authenticateKey (JS) or AuthenticateKeyRequest (Java) for delegated auth.
  • Static token: Pass a string as second arg to newKeyHandshakeUrl (JS) or KeyHandshakeUrlRequest(staticToken, noRequest, callback) (Java) for long-lived reusable URLs.
  • No-request mode: Third arg true (JS) or noRequest = true (Java) — handshake only, no auth challenge.

Check status === 'approved' before granting access. Use session tokens and expiration in your app; the SDK verifies signatures.


Next: Single Payments · Profiles · JWT Tokens

Static Tokens & Physical Authentication

Static tokens make auth URLs reusable: pass a static_token so the same URL can be used many times (e.g. printed on a table, written to NFC).

API

newKeyHandshakeUrl with a static_token — the returned URL does not expire after one use. Use it for location-specific auth (tables, doors, kiosks). Your callback receives the same token context so you can associate the handshake with a place.

HTTP
curl -s -X POST $BASE_URL/key-handshake \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"static_token": "table-14-restaurant-a"}'
# → { "stream_id": "abc123", "url": "nostr+walletconnect://..." }
# The URL can be reused — print it as QR, write to NFC, etc.
# Poll events/abc123 to receive each user's public key as they scan.
JavaScript
const staticToken = 'table-14-restaurant-a';
const authUrl = await client.newKeyHandshakeUrl(
  (mainKey, preferredRelays) => {
    // mainKey + staticToken identify who and where
    handleLocationAuth(staticToken, mainKey);
  },
  staticToken
);
// Share authUrl (QR, NFC, link); it can be reused
Java
import cc.getportal.command.request.KeyHandshakeUrlRequest;
import cc.getportal.command.response.KeyHandshakeUrlResponse;
import cc.getportal.command.notification.KeyHandshakeUrlNotification;

sdk.sendCommand(
    new KeyHandshakeUrlRequest("my-static-token", null, (n) ->
        System.out.println("mainKey: " + n.main_key())),
    (res, err) -> {
        if (err != null) { System.err.println(err); return; }
        System.out.println("URL: " + res.url());
    }
);

You are responsible for generating QR codes or writing to NFC; the API only provides the URL.


Next: Single Payments · Authentication

Single Payments

One-time Lightning payments from an authenticated user. You request a payment; the user approves or rejects in their wallet; you get status updates and, on success, a preimage.

API

requestSinglePayment(mainKey, subkeys, paymentRequest, onStatusChange)

  • paymentRequest: amount (millisats; 1 sat = 1000), currency (Currency.Millisats), description.
  • onStatusChange: callback receives status objects. status values: paid, user_approved, user_rejected, user_failed, timeout, error. On paid use preimage; on failure use reason.
JavaScript
await client.requestSinglePayment(
  userPubkey,
  [],
  {
    amount: 10000,  // 10 sats (millisats)
    currency: Currency.Millisats,
    description: 'Premium - 1 month'
  },
  (status) => {
    if (status.status === 'paid') { /* preimage in status.preimage */ }
  }
);
Java
import cc.getportal.command.request.RequestSinglePaymentRequest;
import cc.getportal.model.SinglePaymentRequestContent;
import cc.getportal.model.Currency;
import java.util.List;

SinglePaymentRequestContent payment = new SinglePaymentRequestContent(
    "Premium - 1 month", 10_000L, Currency.MILLISATS, null, null
);
sdk.sendCommand(
    new RequestSinglePaymentRequest(
        "user-pubkey-hex",
        List.of(),
        payment,
        (n) -> System.out.println("status: " + n.status())
    ),
    (res, err) -> {
        if (err != null) { System.err.println(err); return; }
        System.out.println("invoice: " + res);
    }
);
HTTP
# 1. Request payment (amount in millisats; 1 sat = 1000 millisats)
curl -s -X POST $BASE_URL/payments/single \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "main_key": "USER_PUBKEY_HEX",
    "subkeys": [],
    "description": "Premium - 1 month",
    "amount": 10000,
    "currency": "millisats"
  }'
# → { "stream_id": "xyz789" }

# 2. Poll for payment status
curl -s "$BASE_URL/events/xyz789?after=0" \
  -H "Authorization: Bearer $AUTH_TOKEN"
# → { "events": [{ "index": 0, "data": { "status": "paid", "preimage": "..." } }] }

Poll until status is paid, user_rejected, timeout, or error. See REST API.

Invoice payment: requestInvoicePayment(mainKey, subkeys, { amount, currency, description, invoice, expires_at }, onStatusChange) — pay an external Lightning invoice. Java: RequestInvoicePaymentRequest.

Linked to subscription: Include subscription_id in the single payment request when tying the first payment to a recurring subscription (see Recurring Payments).

Handle all status values; set a timeout in your app if needed. Store preimage for proof of payment.


Next: Recurring Payments · Cashu Tokens · Profiles

Recurring Payments

Subscription-based payments with configurable billing (monthly, weekly, etc.), max payment limits, and user-controlled cancellation.

API

Create subscription: requestRecurringPayment(mainKey, subkeys, paymentRequest) — returns subscription_id, authorized_amount, authorized_recurrence.

paymentRequest: amount, currency (Currency.Millisats), recurrence, expires_at.
recurrence: calendar, first_payment_due (Timestamp), max_payments, until (optional).
calendar: minutely, hourly, daily, weekly, monthly, quarterly, semiannually, yearly.

HTTP
# Request a recurring payment subscription
curl -s -X POST $BASE_URL/payments/recurring \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "main_key": "USER_PUBKEY_HEX",
    "subkeys": [],
    "description": "Monthly subscription",
    "amount": 10000,
    "currency": "millisats",
    "recurrence": {
      "calendar": "monthly",
      "first_payment_due": 1700000000,
      "max_payments": 12
    },
    "expires_at": 1700003600
  }'
# → { "stream_id": "xyz789" }

# Poll for result
curl -s "$BASE_URL/events/xyz789?after=0" \
  -H "Authorization: Bearer $AUTH_TOKEN"
# → { "events": [{ "data": { "subscription_id": "...", "authorized_amount": 10000 } }] }

# Close a subscription
curl -s -X POST $BASE_URL/payments/recurring/close \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"main_key": "USER_PUBKEY_HEX", "subkeys": [], "subscription_id": "SUB_ID"}'
JavaScript
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)
});
// subscription.subscription_id, subscription.authorized_amount, etc.
Java
import cc.getportal.command.request.RequestRecurringPaymentRequest;
import cc.getportal.command.response.RequestRecurringPaymentResponse;
import cc.getportal.model.RecurringPaymentRequestContent;
import cc.getportal.model.RecurrenceInfo;
import cc.getportal.model.Currency;
import java.util.List;

RecurrenceInfo recurrence = new RecurrenceInfo(
    null, "monthly", null, System.currentTimeMillis() / 1000
);
RecurringPaymentRequestContent payment = new RecurringPaymentRequestContent(
    "Monthly sub", 10_000L, Currency.MILLISATS, null, recurrence,
    System.currentTimeMillis() / 1000 + 3600
);
sdk.sendCommand(
    new RequestRecurringPaymentRequest("user-pubkey-hex", List.of(), payment),
    (res, err) -> {
        if (err != null) { System.err.println(err); return; }
        System.out.println("recurring: " + res);
    }
);

Listen for user cancellations: listenClosedRecurringPayment(onClosed) — callback receives subscription_id, main_key, reason; returns unsubscribe function. Java: ListenClosedRecurringPaymentRequest.

Close from provider: closeRecurringPayment(mainKey, subkeys, subscriptionId). Java: CloseRecurringPaymentRequest. HTTP: POST /payments/recurring/close.


Next: Profiles

Profile Management

Fetch and manage user profiles from the Nostr network.

Fetching User Profiles

HTTP
curl -s $BASE_URL/profile/USER_PUBKEY_HEX \
  -H "Authorization: Bearer $AUTH_TOKEN"
# → { "name": "alice", "display_name": "Alice", "picture": "https://...", "nip05": "alice@example.com", ... }
JavaScript
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);
}
Java
import cc.getportal.command.request.FetchProfileRequest;
import cc.getportal.command.response.FetchProfileResponse;

sdk.sendCommand(
    new FetchProfileRequest("user-pubkey-hex"),
    (res, err) -> {
        if (err != null) return;
        System.out.println("profile: " + res.profile());
    }
);

Profile Fields

  • 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)

Cashu ecash tokens: mint, send, request, and burn. Use for tickets, vouchers, or transferable access. Tokens are backed by sats at a mint; you request from users or mint and send.

API

  • requestCashu: Request tokens from a user. Returns status (success / insufficient_funds / rejected), token, reason. On success, burn the token to claim.
  • mintCashu: Mint new tokens. Returns token string.
  • burnCashu: Burn (redeem) a token. Returns amount in millisats.
  • sendCashuDirect: Send a token directly to a user.
HTTP
# Request Cashu tokens from a user (async)
curl -s -X POST $BASE_URL/cashu/request \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "main_key": "USER_PUBKEY_HEX",
    "subkeys": [],
    "mint_url": "https://mint.example.com",
    "unit": "sat",
    "amount": 1000
  }'
# → { "stream_id": "abc123" }
# Poll events/abc123 for result: { "status": "success", "token": "cashuA..." }

# Mint tokens
curl -s -X POST $BASE_URL/cashu/mint \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mint_url": "https://mint.example.com", "unit": "sat", "amount": 500}'

# Burn (redeem) a token
curl -s -X POST $BASE_URL/cashu/burn \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mint_url": "https://mint.example.com", "unit": "sat", "token": "cashuA..."}'

# Send token directly to a user
curl -s -X POST $BASE_URL/cashu/send-direct \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"main_key": "USER_PUBKEY_HEX", "subkeys": [], "token": "cashuA..."}'
JavaScript
// Request from user
const result = await client.requestCashu(userPubkey, [], mintUrl, 'sat', 10000);
if (result.status === 'success') {
  const amount = await client.burnCashu(mintUrl, 'sat', result.token);
}

// Mint and send to user
const token = await client.mintCashu(mintUrl, undefined, 'sat', 10000, 'Ticket');
await client.sendCashuDirect(userPubkey, [], token);
Java
import cc.getportal.command.request.RequestCashuRequest;
import cc.getportal.command.request.MintCashuRequest;
import cc.getportal.command.request.BurnCashuRequest;
import cc.getportal.command.request.SendCashuDirectRequest;
import java.util.List;

// requestCashu
sdk.sendCommand(
    new RequestCashuRequest("https://mint.example.com", "sat", 1000L, "recipient-pubkey", List.of()),
    (res, err) -> { if (err == null) System.out.println(res); }
);

// mintCashu
sdk.sendCommand(
    new MintCashuRequest("https://mint.example.com", null, "sat", 500L, "tip"),
    (res, err) -> { if (err == null) System.out.println(res); }
);

// burnCashu
sdk.sendCommand(
    new BurnCashuRequest("https://mint.example.com", null, "sat", "cashu-token-string"),
    (res, err) -> { if (err == null) System.out.println(res); }
);

// sendCashuDirect
sdk.sendCommand(
    new SendCashuDirectRequest("user-pubkey-hex", List.of(), "cashu-token-string"),
    (res, err) -> { if (err == null) System.out.println(res); }
);

Burn tokens immediately after receiving to prevent reuse. For your own mint and custom units, see Running a Mint. Public mints: e.g. minibits.cash, bitcoinmints.com — see bitcoinmints.com.


Next: JWT Tokens · Single Payments

Running a Custom Cashu Mint

Portal uses a Cashu CDK fork. Run your own mint to issue custom tokens (e.g. event tickets, vouchers) with full control and optional custom units/metadata.

Docker

docker pull getportal/cdk-mintd:latest

Create config.toml (minimal):

[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

[portal_wallet.unit_info.sat]
title = "Satoshi"
description = "Standard Bitcoin satoshi token"
show_individually = false
url = "https://yourdomain.com"

[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"

Run:

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>" \
  getportal/cdk-mintd:latest

Verify: curl http://localhost:3338/v1/info

Config reference

  • info — url, listen_host, listen_port
  • mint_info — name, description
  • ln — ln_backend = "portalwallet", mint_max, melt_max
  • portal_wallet.supported_units — unit name = keyset size (e.g. sat = 32)
  • portal_wallet.unit_info. — title, description, show_individually (false = fungible, true = tickets), optional front_card_background, back_card_background; kind.Event with date, location
  • auth — enabled_mint, enabled_melt, etc.; auth.method.Static — token for mint auth

Use the static token from config when calling mintCashu / burnCashu with your mint URL (see Cashu Tokens).

Build from source

Portal CDK: github.com/PortalTechnologiesInc/cdk. Build with Cargo or Nix; run with MINT_CONFIG and MNEMONIC_FILE or equivalent env.

Production

Run behind HTTPS (reverse proxy). Use Docker Compose or mount config and data; set CONFIG_PATH, RUST_LOG, DATA_DIR if needed.


Next: Cashu Tokens · Docker Deployment

JWT Tokens (Session Management)

Verify JWTs issued by user wallets for API auth. Typically the wallet issues the token after authentication; you verify it.

API

  • verifyJwt(publicKey, token): Returns target_key; throws if invalid or expired.
  • issueJwt(targetKey, durationHours): Issue a JWT (e.g. for service-to-service); less common than verification.
HTTP
# Issue a JWT
curl -s -X POST $BASE_URL/jwt/issue \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"target_key": "TARGET_PUBKEY_HEX", "duration_hours": 24}'
# → { "token": "eyJ..." }

# Verify a JWT
curl -s -X POST $BASE_URL/jwt/verify \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"public_key": "PUBKEY_HEX", "token": "eyJ..."}'
# → { "target_key": "..." }
JavaScript
const claims = await client.verifyJwt(servicePublicKey, tokenFromUser);
// claims.target_key — user identity
Java
import cc.getportal.command.request.IssueJwtRequest;
import cc.getportal.command.request.VerifyJwtRequest;
import cc.getportal.command.response.IssueJwtResponse;
import cc.getportal.command.response.VerifyJwtResponse;

// issueJwt
sdk.sendCommand(
    new IssueJwtRequest("target-pubkey-hex", 24L),
    (res, err) -> {
        if (err != null) return;
        System.out.println("jwt: " + res.token());
    }
);

// verifyJwt
sdk.sendCommand(
    new VerifyJwtRequest("pubkey-hex", "jwt-token-string"),
    (res, err) -> {
        if (err != null) { System.err.println(err); return; }
        System.out.println("valid: " + res);
    }
);

Next: Relay Management

Relay Management

Manage Nostr relays used by your Portal instance. Relays store and forward Nostr messages.

API

  • addRelay(relay): Add a relay (e.g. wss://relay.damus.io). Returns confirmation.
  • removeRelay(relay): Remove a relay.
HTTP
# Add a relay
curl -s -X POST $BASE_URL/relays \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"relay": "wss://relay.damus.io"}'

# Remove a relay
curl -s -X DELETE $BASE_URL/relays \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"relay": "wss://relay.damus.io"}'
JavaScript
await client.addRelay('wss://relay.damus.io');
await client.removeRelay('wss://relay.damus.io');
Java
import cc.getportal.command.request.AddRelayRequest;
import cc.getportal.command.request.RemoveRelayRequest;

sdk.sendCommand(new AddRelayRequest("wss://relay.damus.io"), (res, err) -> {
    if (err != null) System.err.println(err);
});
sdk.sendCommand(new RemoveRelayRequest("wss://relay.damus.io"), (res, err) -> {
    if (err != null) System.err.println(err);
});

Common relays: wss://relay.damus.io, wss://relay.snort.social, wss://nos.lol, wss://relay.nostr.band. Use several for redundancy; respect user preferred relays from the key handshake when relevant.


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:0.3.0

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:0.3.0

# 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!