Portal
Project Homepage | Repository | Become a supporter
Portal lets you add authentication and payments to your app — without collecting personal data.
What do you need?
I need authentication & payments → Build with Portal
Passwordless login, one-time and recurring payments, digital tickets — all privacy-preserving.
How it works
- Sign up at hub.getportal.cc
- Create a Portal instance (we host it for you)
- Get your API credentials
- Integrate with our SDK or REST API
That's it. No servers to run. Portal handles the rest.
Want to self-host? You can run your own Portal instance with Docker. See Self-Hosting & Advanced.
SDKs & API
| Option | When to use |
|---|---|
| HTTP / REST | Any language — Python, Go, Ruby, PHP, etc. |
| JavaScript SDK | Node.js and browser apps |
| Java SDK | JVM apps (Android, Spring, etc.) |
All options talk to the same REST API under the hood. The SDKs add typed wrappers and auto-polling.
Open source
Portal is open source. View on GitHub
Platform — Getting Started
Set up Portal for authentication, payments, profiles, and more. This guide gets you running in minutes.
1. Get a Portal instance
Option A: PortalHub (recommended)
Sign up at hub.getportal.cc and create a Portal instance. PortalHub hosts and runs it for you — no servers needed.
You'll get:
- An instance URL (e.g.
https://your-instance.hub.getportal.cc) - An API auth token
Option B: Self-host with Docker
If you prefer to run your own instance:
docker run -d -p 3000:3000 \
-e PORTAL__AUTH__AUTH_TOKEN=my-secret-token \
-e PORTAL__NOSTR__PRIVATE_KEY=$(openssl rand -hex 32) \
getportal/sdk-daemon:0.4.1
Check it's running:
curl http://localhost:3000/health
# → OK
See Docker Deployment for production setup.
3. Install an SDK (optional)
Portal exposes a standard HTTP REST API — you can use any language. SDKs add convenience.
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+ required.
Gradle:
repositories {
maven { url 'https://jitpack.io' }
}
dependencies {
implementation 'com.github.PortalTechnologiesInc:java-sdk:0.4.1'
}
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.1</version>
</dependency>
4. First request — authenticate a user
Generate a URL for a user to log in:
# 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());
var result = client.pollUntilComplete(operation);
System.out.println("User key: " + result.main_key());
What's next?
- Authentication — Full auth flow with subkeys and static tokens
- Single Payments — Accept one-time payments
- Recurring Payments — Set up subscriptions
- Docker Deployment — Production deployment
- Environment Variables — All configuration options
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 (64 chars); convert nsec with nak decode nsec1.... |
Troubleshooting: Full troubleshooting guide
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
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
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
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
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
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
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.4.1
Tip: pin a specific version in production (e.g.
0.4.1) 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.4.1
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.
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.
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.1
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. |
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
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
How Portal Works (Nostr & Lightning)
Portal is built on two open protocols: Nostr for identity and messaging, and the Lightning Network for payments. This page explains the technology under the hood — you don't need to understand any of this to use Portal, but it's here if you're curious.
Nostr — Decentralized Identity
Nostr (Notes and Other Stuff Transmitted by Relays) is a simple, open protocol for decentralized communication.
Identity is a key pair
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 — like a username, but cryptographically secure.
No email, no phone number, no centralized authority needed. Portal uses Nostr keys for passwordless authentication.
Relays
Relays are simple servers that store and forward messages (called "events"). Unlike traditional services:
- Anyone can run a relay
- You can connect to multiple relays simultaneously
- Relays don't own your data
- If one relay goes down, your messages exist on other relays
Portal uses relays to communicate between your application and the user's wallet.
Events
Everything in Nostr is a signed JSON message called an "event" — social media posts, direct messages, authentication requests, payment requests, and more.
How Portal uses Nostr
When a user authenticates with Portal:
- Your application generates an authentication challenge
- The challenge is published to Nostr relays
- The user's wallet picks up the challenge
- The user approves or denies
- The response is published back via relays
- Your application receives the response
All peer-to-peer through relays, with no central authentication server.
Lightning Network — Instant Payments
The Lightning Network is a Layer 2 payment protocol built on Bitcoin that enables fast, low-cost transactions.
Why Lightning?
| Bitcoin (on-chain) | Lightning | |
|---|---|---|
| Speed | 10+ minutes | Sub-second |
| Fees | Variable, can be high | Minimal (< 1 sat) |
| Privacy | Public blockchain | Off-chain |
| Micropayments | Impractical | Native support |
How Portal uses Lightning
Portal uses Nostr Wallet Connect (NWC), a protocol that allows:
- Requesting payments through Nostr messages
- User approval through their Lightning wallet
- Real-time payment status updates
- Non-custodial flow (users keep control of their funds)
The payment flow:
- 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
- Lightning payment: Wallet sends payment via Lightning
- Confirmation: Your app receives real-time confirmation
Compatible wallets include Alby, Mutiny, Breez, and any NWC-compatible wallet.
Cashu — Digital Tickets & Vouchers
Cashu is an ecash protocol. Portal uses Cashu tokens as digital tickets and vouchers.
Key properties:
- Bearer tokens: Whoever holds the token can use it
- Privacy: Blind signatures mean the mint can't track who uses what
- Programmable: Custom units, metadata, expiration
In Portal, Cashu tokens are used for:
- Age verification proofs
- Event tickets
- Access vouchers
- Any transferable digital credential
Learn more
Back to: Documentation Home
JavaScript / TypeScript SDK
The official Portal SDK for Node.js and browser apps.
npm: portal-sdk · Source: GitHub
Installation
npm install portal-sdk
Requires Node.js 18+ and optionally TypeScript 4.5+.
Compatibility: The SDK
major.minorversion must match the SDK Daemon (getportal/sdk-daemon). Patch versions are independent. See Versioning.
Quick start
import { PortalClient } from 'portal-sdk';
const client = new PortalClient({
baseUrl: 'https://your-instance.hub.getportal.cc',
authToken: 'your-auth-token',
});
// Authenticate a user
const op = await client.newKeyHandshakeUrl();
console.log('Share with user:', op.url);
const result = await client.poll(op);
console.log('User key:', result.main_key);
Configuration
Choose how you want to receive async results:
// Manual polling (default) — call poll(op) yourself
const client = new PortalClient({
baseUrl: 'https://your-instance.hub.getportal.cc',
authToken: 'your-auth-token',
});
// Auto-polling — background interval resolves operations automatically
const client = new PortalClient({
baseUrl,
authToken,
autoPollingIntervalMs: 500,
});
// call client.destroy() to stop the scheduler when done
// Webhooks — portal-rest POSTs to your server
const client = new PortalClient({
baseUrl,
authToken,
webhookSecret: 'my-secret',
});
| Option | Required | Description |
|---|---|---|
baseUrl | Yes | HTTP base URL of your Portal instance |
authToken | Yes | Bearer token matching PORTAL__AUTH__AUTH_TOKEN |
autoPollingIntervalMs | No | Enable auto-polling; interval in ms (e.g. 500) |
webhookSecret | No | Enable webhook mode with HMAC-SHA256 verification |
API Reference
Auth & Users
| Method | Description |
|---|---|
newKeyHandshakeUrl(onKeyHandshake, staticToken?, noRequest?) | Get URL for user key handshake; callback runs when user completes. |
authenticateKey(mainKey, subkeys?) | Authenticate a user key. Returns AuthResponseData with status, session_token. |
Payments
| Method | Description |
|---|---|
requestSinglePayment(mainKey, subkeys, paymentRequest, onStatusChange) | Request a one-time Lightning payment. |
requestRecurringPayment(mainKey, subkeys, paymentRequest) | Request a recurring (subscription) payment. |
requestInvoicePayment(mainKey, subkeys, paymentRequest, onStatusChange) | Pay an invoice on behalf of a user. |
requestInvoice(recipientKey, subkeys, content) | Request an invoice. |
closeRecurringPayment(mainKey, subkeys, subscriptionId) | Close a recurring subscription. |
listenClosedRecurringPayment(onClosed) | Listen for user cancellations; returns unsubscribe function. |
Profiles & Identity
| Method | Description |
|---|---|
fetchProfile(mainKey) | Fetch a user's profile (Profile | null). |
setProfile(profile) | Set or update a profile. |
fetchNip05Profile(nip05) | Resolve a NIP-05 identifier. |
JWT
| Method | Description |
|---|---|
issueJwt(target_key, duration_hours) | Issue a JWT for the given key. |
verifyJwt(public_key, token) | Verify a JWT and return claims. |
Cashu & Relays
| Method | Description |
|---|---|
requestCashu(...) | Request Cashu tokens from a user. |
sendCashuDirect(...) | Send Cashu tokens to a user. |
mintCashu(...) | Mint Cashu tokens. |
burnCashu(...) | Burn (redeem) Cashu tokens. |
addRelay(relay) | Add a relay. |
removeRelay(relay) | Remove a relay. |
Events
| Method | Description |
|---|---|
on(eventType | EventCallbacks, callback?) | Register listener: on('connected', fn) or on({ onConnected, onDisconnected, onError }). |
off(eventType, callback) | Remove a listener. |
Error Handling
The SDK throws PortalSDKError with a code property:
import { PortalSDKError } from 'portal-sdk';
try {
const { url } = await client.newKeyHandshakeUrl();
} catch (err) {
if (err instanceof PortalSDKError) {
console.error(err.code, err.message);
}
}
Error codes
| Code | When |
|---|---|
NOT_CONNECTED | Method called before connect() or after disconnect. |
CONNECTION_TIMEOUT | Connection did not open in time. |
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. |
PARSE_ERROR | Failed to parse a message. |
Types
Currency— e.g.Currency.MillisatsTimestamp—Timestamp.fromDate(date),Timestamp.fromNow(seconds),toDate(),toJSON()Profile—id,pubkey,name,display_name,picture,about,nip05SinglePaymentRequestContent,RecurringPaymentRequestContent,InvoiceRequestContentAuthResponseData,InvoiceStatus,RecurringPaymentStatus
Full types are exported from portal-sdk; use your editor's IntelliSense or the package source.
See also: REST API · Java SDK · OpenAPI Reference
Java SDK
The official Portal SDK for JVM apps (Android, Spring, etc.).
Source: GitHub
Installation
Gradle (build.gradle):
repositories {
maven { url 'https://jitpack.io' }
}
dependencies {
implementation 'com.github.PortalTechnologiesInc:java-sdk:0.4.1'
}
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.1</version>
</dependency>
Requires Java 17+.
Compatibility: The SDK
major.minorversion must match the SDK Daemon (getportal/sdk-daemon). Patch versions are independent. See Versioning.
Quick start
import cc.getportal.PortalClient;
import cc.getportal.PortalClientConfig;
PortalClient client = new PortalClient(
PortalClientConfig.create("http://localhost:3000", "your-auth-token")
);
// Authenticate a user
var operation = client.newKeyHandshakeUrl();
System.out.println("Share with user: " + operation.url());
var result = client.pollUntilComplete(operation);
System.out.println("User key: " + result.main_key());
Configuration
// Manual polling (default)
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")
);
API Reference
All commands use sdk.sendCommand(request, (response, err) -> { ... }).
Auth & Users
| Request class | Description |
|---|---|
KeyHandshakeUrlRequest(notificationCallback) | Get URL for user key handshake. KeyHandshakeUrlResponse.url() |
KeyHandshakeUrlRequest(staticToken, noRequest, callback) | With static token and/or no-request mode. |
AuthenticateKeyRequest(mainKey, subkeys) | Authenticate a user key. |
Payments
| Request class | Description |
|---|---|
RequestSinglePaymentRequest(mainKey, subkeys, paymentContent, statusCallback) | 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. |
Profiles & Identity
| Request class | Description |
|---|---|
FetchProfileRequest(mainKey) | Fetch profile. Response: FetchProfileResponse.profile() |
SetProfileRequest(profile) | Set or update profile. Profile(name, displayName, picture, nip05) |
FetchNip05ProfileRequest(nip05) | Resolve NIP-05 identifier. |
JWT
| Request class | Description |
|---|---|
IssueJwtRequest(targetKey, durationHours) | Issue a JWT. Response: IssueJwtResponse.token() |
VerifyJwtRequest(publicKey, token) | Verify a JWT. Response: VerifyJwtResponse |
Cashu & Relays
| Request class | Description |
|---|---|
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. |
AddRelayRequest(relayUrl) | Add a relay. |
RemoveRelayRequest(relayUrl) | Remove a relay. |
Error Handling
Check the err parameter in each sendCommand callback:
sdk.sendCommand(someRequest, (response, err) -> {
if (err != null) {
System.err.println("Command failed: " + err);
return;
}
// use response
});
Types
| Type | Description |
|---|---|
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) | Profile model |
All classes in cc.getportal.command.request, cc.getportal.command.response, cc.getportal.command.notification, cc.getportal.model.
See also: JavaScript SDK · REST API · OpenAPI Reference
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 viewer above doesn't load, open the spec directly:
- openapi.yaml on GitHub
- Paste the raw URL into Redocly or Swagger Editor
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.4.1
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.4.1
# 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
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.4.1↔ SDK Daemon0.4.1✅ - 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.1:
# Docker
docker pull getportal/sdk-daemon:0.4.1
# npm
npm install portal-sdk@0.4.1
// Gradle
implementation 'com.github.PortalTechnologiesInc:java-sdk:0.4.1'
Current versions
| Component | Version |
|---|---|
SDK Daemon (getportal/sdk-daemon) | 0.4.1 |
TypeScript SDK (portal-sdk) | 0.4.1 |
| Java SDK | 0.4.1 |
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!