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.
- Run the Portal daemon — self-host or develop locally: run
getportal/sdk-daemonwith Docker (see Quick Start). - Call the REST API — use any HTTP client (curl, Python, Go, Ruby, PHP…), or use the official SDKs for JavaScript/TypeScript and Java.
- Auth, payments, tickets — generate auth URLs (users approve with Nostr wallet), request single or recurring Lightning payments, issue Cashu tokens.
Integration options
| Option | When to use |
|---|---|
| HTTP / REST | Any language — Python, Go, Ruby, PHP, Rust, etc. No SDK needed. |
| JavaScript / TypeScript SDK | Node.js and browser apps. Handles polling and webhooks automatically. |
| Java SDK | JVM 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
| Section | For |
|---|---|
| Getting Started | Quick Start, Docker, env vars, building from source. |
| SDK & REST API | REST API, SDK install, usage, config, errors, OpenAPI reference. |
| Guides | Auth, payments, profiles, Cashu, JWT, relays — with curl, JS, and Java examples. |
| Resources | FAQ, 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:
- Decentralized Identity: Users control their own keys and identity
- No Central Server: Communication happens through distributed relays
- Censorship Resistance: No single point of control
- Privacy: Direct peer-to-peer messaging
- Interoperability: Standard protocol that works across applications
Nostr in Portal's Authentication Flow
When a user authenticates with Portal:
- Your application generates an authentication challenge
- The challenge is published to Nostr relays
- The user's wallet (like Alby, Mutiny, or others) picks up the challenge
- The user approves or denies the authentication
- The response is published back to Nostr
- Your application receives the response
All of this happens peer-to-peer through Nostr relays, with no central authentication server.
Learn More
Next: Learn about Lightning Network
What is Lightning Network?
The Lightning Network is a "Layer 2" payment protocol built on top of Bitcoin that enables fast, low-cost transactions.
Why Lightning?
Traditional Bitcoin transactions:
- Can take 10+ minutes to confirm
- Have transaction fees that can be high during busy times
- Are recorded on the blockchain forever
Lightning Network transactions:
- Are instant (sub-second)
- Have minimal fees (often less than 1 satoshi)
- Are private (not all details go on the blockchain)
- Enable micropayments (pay fractions of a cent)
How It Works (Simplified)
- Payment Channels: Two parties open a channel by creating a special Bitcoin transaction
- Off-Chain Transactions: They can then make unlimited instant transactions between each other
- Network of Channels: Payments can route through multiple channels to reach any destination
- Settlement: Channels can be closed at any time, settling the final balance on the Bitcoin blockchain
Lightning in Portal
Portal uses Lightning Network for:
Single Payments
One-time payments for purchases, tips, or services:
await client.requestSinglePayment(
userKey,
[],
{
amount: 1000, // 1 sat (1000 millisats)
currency: Currency.Millisats,
description: "Premium subscription"
},
(status) => {
if (status.status === 'paid') {
console.log('Payment received!');
}
}
);
Recurring Payments
Subscription-based payments with automatic billing:
await client.requestRecurringPayment(
userKey,
[],
{
amount: 10000, // 10 sats per month
currency: Currency.Millisats,
recurrence: {
calendar: "monthly",
first_payment_due: Timestamp.fromNow(86400),
max_payments: 12
},
expires_at: Timestamp.fromNow(3600)
}
);
Nostr Wallet Connect (NWC)
Portal uses Nostr Wallet Connect, a protocol that allows:
- Requesting payments through Nostr messages
- User approval through their Lightning wallet
- Real-time payment status updates
- Non-custodial payment flow (users maintain control of funds)
The user's Lightning wallet could be:
Payment Flow in Portal
- Payment Request: Your app requests a payment through Portal
- Nostr Message: Request is sent to the user via Nostr
- Wallet Notification: User's wallet shows the payment request
- User Approval: User approves or denies the payment
- Lightning Payment: Wallet sends payment via Lightning Network
- Confirmation: Your app receives real-time payment confirmation
All within seconds, with minimal fees.
Benefits for Your Business
- Instant Settlement: Receive payments immediately
- Global Reach: Accept payments from anyone, anywhere
- No Chargebacks: Bitcoin payments are final
- Low Fees: Typically < 1% (often much less)
- No Middlemen: Direct payment from customer to you
- Privacy: No personal information required
Learn More
Next: Start integrating Portal with the Quick Start Guide
Quick Start
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)
Nothing to install. Set your base URL and token:
export BASE_URL=http://localhost:3000
export AUTH_TOKEN=my-secret-token
npm install portal-sdk
- Node.js 18+
- See SDK Installation.
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:
# 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.
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);
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
| Issue | Fix |
|---|---|
| Connection refused | Portal not running or wrong port. Check docker ps. |
| 401 Unauthorized | Token must match PORTAL__AUTH__AUTH_TOKEN. |
| Invalid Nostr key | Use 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:latestto 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
| Variable | Description |
|---|---|
PORTAL__AUTH__AUTH_TOKEN | API auth token (required). |
PORTAL__NOSTR__PRIVATE_KEY | Nostr private key hex (required). |
PORTAL__WALLET__LN_BACKEND | none, nwc, or breez. |
PORTAL__WALLET__NWC__URL | NWC URL when ln_backend=nwc. |
PORTAL__WALLET__BREEZ__API_KEY | Breez API key when ln_backend=breez. |
PORTAL__WALLET__BREEZ__MNEMONIC | Breez mnemonic when ln_backend=breez. |
PORTAL__NOSTR__RELAYS | Comma-separated relay URLs. |
PORTAL__NOSTR__SUBKEY_PROOF | Proof 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 key | Env var | Required | Default | Description |
|---|---|---|---|---|
info.listen_port | PORTAL__INFO__LISTEN_PORT | No | 3000 | HTTP port. |
auth.auth_token | PORTAL__AUTH__AUTH_TOKEN | Yes | — | Bearer token for API access. |
nostr.private_key | PORTAL__NOSTR__PRIVATE_KEY | Yes | — | Nostr private key (64 hex chars). |
nostr.relays | PORTAL__NOSTR__RELAYS | No | wss://relay.nostr.net, wss://relay.damus.io, wss://relay.getportal.cc | Comma-separated relay URLs. |
nostr.subkey_proof | PORTAL__NOSTR__SUBKEY_PROOF | No | — | Subkey delegation proof. |
Database
| Config key | Env var | Required | Default | Description |
|---|---|---|---|---|
database.path | PORTAL__DATABASE__PATH | No | portal-rest.db | SQLite file path. Relative paths resolve under ~/.portal-rest/. |
Wallet
| Config key | Env var | Required | Default | Description |
|---|---|---|---|---|
wallet.ln_backend | PORTAL__WALLET__LN_BACKEND | No | none | none, nwc, or breez. |
wallet.nwc.url | PORTAL__WALLET__NWC__URL | If ln_backend=nwc | — | NWC connection URL. |
wallet.breez.api_key | PORTAL__WALLET__BREEZ__API_KEY | If ln_backend=breez | — | Breez API key. |
wallet.breez.mnemonic | PORTAL__WALLET__BREEZ__MNEMONIC | If ln_backend=breez | — | Breez wallet mnemonic. |
Webhooks
Webhooks are an alternative to polling — the daemon will POST events to your endpoint as they arrive.
| Config key | Env var | Required | Default | Description |
|---|---|---|---|---|
webhook.url | PORTAL__WEBHOOK__URL | No | — | URL to receive webhook events. |
webhook.secret | PORTAL__WEBHOOK__SECRET | No | — | Shared 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 key | Env var | Required | Default | Description |
|---|---|---|---|---|
profile.name | PORTAL__PROFILE__NAME | No | — | Username (no spaces). |
profile.display_name | PORTAL__PROFILE__DISPLAY_NAME | No | — | Display name. |
profile.picture | PORTAL__PROFILE__PICTURE | No | — | Avatar URL. |
profile.nip05 | PORTAL__PROFILE__NIP05 | No | — | NIP-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
| Problem | Fix |
|---|---|
401 Unauthorized | Token must match PORTAL__AUTH__AUTH_TOKEN. |
Invalid Nostr key | Must be 64 hex chars. Convert nsec: nak decode nsec1.... |
| Relays not connecting | Use wss:// URLs; e.g. wss://relay.damus.io. |
| DB path error | Use 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):
| Component | Package |
|---|---|
portal-rest (SDK Daemon) | Docker: getportal/sdk-daemon |
| TypeScript SDK | npm: portal-sdk |
| Java SDK | JitPack: 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 Daemon0.3.0✅ - SDK
0.3.4↔ SDK Daemon0.3.1✅ (patch is irrelevant) - SDK
0.3.x↔ SDK Daemon0.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:
- Update your SDK dependency to the matching version.
- Update the Docker image tag to the matching version.
- 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
| Component | Version |
|---|---|
SDK Daemon (getportal/sdk-daemon) | 0.3.0 |
TypeScript SDK (portal-sdk) | 0.3.0 |
| Java SDK | 0.3.0 |
Installation
Any language: REST API · JavaScript/TypeScript: npm · Java: GitHub
Requirements
No SDK needed. Any HTTP client works: curl, Python, Go, Ruby, PHP, etc.
- Portal endpoint and auth token
- That's it.
- Node.js 18+
- TypeScript 4.5+ (optional)
- Portal endpoint and auth token
- Java 17+
- Portal endpoint and auth token
Install
Nothing to install. Set your base URL and token:
export BASE_URL=http://localhost:3000
export AUTH_TOKEN=your-secret-token
npm install portal-sdk
Or yarn add portal-sdk / pnpm add portal-sdk.
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
# 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.
import { PortalClient } from 'portal-sdk';
const client = new PortalClient({
baseUrl: 'http://localhost:3000',
authToken: 'your-auth-token'
});
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.minorversion must match the SDK Daemon (getportal/sdk-daemon)major.minor. Patch versions are independent. See Versioning & Compatibility.
Basic Usage
Quick example
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.
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);
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
- Auth:
newKeyHandshakeUrl,authenticateKey— see Authentication - Payments:
requestSinglePayment,requestRecurringPayment,requestInvoicePayment— see Guides - Profiles:
fetchProfile - Full reference: REST API · OpenAPI · SDK API Reference
Configuration
Client options
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.
Pass options to PortalClient:
| Option | Required | Description |
|---|---|---|
baseUrl | Yes | HTTP base URL (e.g. http://localhost:3000) |
authToken | Yes | Bearer token matching PORTAL__AUTH__AUTH_TOKEN |
autoPollingIntervalMs | No | Enable 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();
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
The API returns standard HTTP status codes:
| Status | Meaning |
|---|---|
200 | Success |
400 | Bad request (invalid parameters) |
401 | Unauthorized (wrong or missing auth token) |
404 | Not found (stream_id expired or unknown) |
409 | Conflict (operation already in progress) |
500 | Internal 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" } }] }
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
| Code | When |
|---|---|
NOT_CONNECTED | Method called before connect() or after disconnect. |
CONNECTION_TIMEOUT | Connection did not open within connectTimeout. |
CONNECTION_CLOSED | Socket closed unexpectedly. |
AUTH_FAILED | Invalid or rejected auth token. |
UNEXPECTED_RESPONSE | Server sent unexpected response type. |
SERVER_ERROR | Server returned an error (err.message). |
PARSE_ERROR | Failed to parse a message; optional err.details. |
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
| Method | Description |
|---|---|
connect(): Promise<void> | Connect to Portal. Call once before other methods. |
disconnect(): void | Close connection and clear state. |
authenticate(token: string): Promise<void> | Authenticate with your auth token (required after connect). |
Auth and users
| Method | Description |
|---|---|
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). |
| Class / method | Description |
|---|---|
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
| Method | Description |
|---|---|
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. |
| Request class | Description |
|---|---|
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
| Method | Description |
|---|---|
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. |
| Request class | Description |
|---|---|
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
| Method | Description |
|---|---|
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. |
| Request class | Description |
|---|---|
IssueJwtRequest(targetKey, durationHours) | Issue a JWT. Response: IssueJwtResponse.token(). |
VerifyJwtRequest(publicKey, token) | Verify a JWT. Response: VerifyJwtResponse (claims). |
Relays and Cashu
| Method | Description |
|---|---|
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 |
| Request class | Description |
|---|---|
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
| Method | Description |
|---|---|
on(eventType | EventCallbacks, callback?): void | Register listener, e.g. on('connected', fn) or on({ onConnected, onDisconnected, onError }). |
off(eventType, callback): void | Remove a listener. |
Responses and notifications are delivered in the sendCommand callback.
Types overview
- 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.
| Type | Description |
|---|---|
PortalRequest, PortalResponse, PortalNotification | Base types for sendCommand. |
Currency | e.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:
- Start the operation → receive a
stream_id - 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
| Endpoint | Method | Description |
|---|---|---|
/health | GET | Health check |
/version | GET | Daemon version |
/key-handshake | POST | Generate auth URL for user |
/authenticate-key | POST | Authenticate a key |
/payments/single | POST | Request single payment |
/payments/recurring | POST | Request recurring payment |
/payments/recurring/close | POST | Close recurring subscription |
/invoices/request | POST | Request an invoice |
/invoices/pay | POST | Pay a BOLT11 invoice |
/cashu/request | POST | Request Cashu tokens |
/profile/{main_key} | GET | Fetch user profile |
/events/{stream_id} | GET | Poll 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
- Generate auth URL:
newKeyHandshakeUrl(onKeyHandshake, staticToken?, noRequest?)— callback runs when the user completes the handshake. - Authenticate the key:
authenticateKey(mainKey, subkeys?)— returns AuthResponseData with status (approved / declined), optional session_token, reason.
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
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");
}
);
# 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) orAuthenticateKeyRequest(Java) for delegated auth. - Static token: Pass a string as second arg to
newKeyHandshakeUrl(JS) orKeyHandshakeUrlRequest(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.
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.
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
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.
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 */ }
}
);
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);
}
);
# 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.
# 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"}'
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.
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
curl -s $BASE_URL/profile/USER_PUBKEY_HEX \
-H "Authorization: Bearer $AUTH_TOKEN"
# → { "name": "alice", "display_name": "Alice", "picture": "https://...", "nip05": "alice@example.com", ... }
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);
}
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.
# 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..."}'
// 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);
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.
# 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": "..." }
const claims = await client.verifyJwt(servicePublicKey, tokenFromUser);
// claims.target_key — user identity
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.
# 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"}'
await client.addRelay('wss://relay.damus.io');
await client.removeRelay('wss://relay.damus.io');
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:
- Check existing issues: GitHub Issues
- Search documentation: Use Ctrl+F or search feature
- Enable debug logging: Capture detailed logs
- Create minimal reproduction: Simplify to smallest failing example
- Open an issue: Include:
- Portal version
- SDK version
- Environment (OS, Node version)
- Complete error messages
- Steps to reproduce
Back to: Documentation Home
Contributing to Portal
We welcome contributions to Portal! This guide will help you get started.
Ways to Contribute
- Report bugs - Open an issue on GitHub
- Suggest features - Share your ideas
- Improve documentation - Fix typos, add examples
- Submit code - Fix bugs or implement features
- Answer questions - Help others in discussions
Development Setup
- Fork the repository
git clone https://github.com/PortalTechnologiesInc/lib.git
cd lib
- Set up development environment
# Using Nix (recommended)
nix develop
# Or manually with Cargo
cargo build
- Run tests
cargo test
-
Make your changes
-
Run linting
cargo fmt
cargo clippy
- Submit a pull request
Code Style
- Follow Rust conventions
- Use
cargo fmtbefore committing - Fix
cargo clippywarnings - Write tests for new features
Documentation
When adding features:
- Update relevant documentation
- Add code examples
- Update the changelog
Questions?
- Open a GitHub issue
- Join community discussions
- Read the existing documentation
Thank you for contributing to Portal!