Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Portal

Project Homepage | Repository | Become a supporter

Portal 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

  1. Sign up at hub.getportal.cc
  2. Create a Portal instance (we host it for you)
  3. Get your API credentials
  4. 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

OptionWhen to use
HTTP / RESTAny language — Python, Go, Ruby, PHP, etc.
JavaScript SDKNode.js and browser apps
Java SDKJVM 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

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.

HTTP

Nothing to install. Set your base URL and token:

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

Node.js 18+ required.

Java

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:

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

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

See REST API for the full async polling pattern.

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

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

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

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

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

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

var result = client.pollUntilComplete(operation);
System.out.println("User key: " + result.main_key());

What's next?

Common issues

IssueFix
Connection refusedPortal not running or wrong port. Check docker ps.
401 UnauthorizedToken must match PORTAL__AUTH__AUTH_TOKEN.
Invalid Nostr keyUse hex (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

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

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

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

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

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

See REST API for the full polling flow.

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

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


Next: Single Payments · Profiles · JWT Tokens

Single Payments

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

API

requestSinglePayment(mainKey, subkeys, paymentRequest, onStatusChange)

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

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

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

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

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

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

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


Next: Recurring Payments · Cashu Tokens · Profiles

Recurring Payments

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

API

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

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

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

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

# Close a subscription
curl -s -X POST $BASE_URL/payments/recurring/close \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"main_key": "USER_PUBKEY_HEX", "subkeys": [], "subscription_id": "SUB_ID"}'
JavaScript
const subscription = await client.requestRecurringPayment(userPubkey, [], {
  amount: 10000,
  currency: Currency.Millisats,
  recurrence: {
    calendar: 'monthly',
    first_payment_due: Timestamp.fromNow(86400),
    max_payments: 12
  },
  expires_at: Timestamp.fromNow(3600)
});
// subscription.subscription_id, subscription.authorized_amount, etc.
Java
import cc.getportal.command.request.RequestRecurringPaymentRequest;
import cc.getportal.command.response.RequestRecurringPaymentResponse;
import cc.getportal.model.RecurringPaymentRequestContent;
import cc.getportal.model.RecurrenceInfo;
import cc.getportal.model.Currency;
import java.util.List;

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

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

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


Next: Profiles

Cashu Tokens (Tickets)

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

API

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

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

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

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

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

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

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

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

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

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


Next: JWT Tokens · Single Payments

Profile Management

Fetch and manage user profiles from the Nostr network.

Fetching User Profiles

HTTP
curl -s $BASE_URL/profile/USER_PUBKEY_HEX \
  -H "Authorization: Bearer $AUTH_TOKEN"
# → { "name": "alice", "display_name": "Alice", "picture": "https://...", "nip05": "alice@example.com", ... }
JavaScript
const profile = await client.fetchProfile(userPubkey);

if (profile) {
  console.log('Name:', profile.name);
  console.log('Display Name:', profile.display_name);
  console.log('Picture:', profile.picture);
  console.log('About:', profile.about);
  console.log('NIP-05:', profile.nip05);
}
Java
import cc.getportal.command.request.FetchProfileRequest;
import cc.getportal.command.response.FetchProfileResponse;

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

Profile Fields

  • name: Username (no spaces)
  • display_name: Display name (can have spaces)
  • picture: Profile picture URL
  • about: Bio/description
  • nip05: Nostr verified identifier (like email)

Next: JWT Tokens

JWT Tokens (Session Management)

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

API

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

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

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

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

Next: Relay Management

Static Tokens & Physical Authentication

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

API

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

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

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

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


Next: Single Payments · Authentication

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 :latest to avoid unexpected updates. The image is multi-arch (amd64 + arm64) — Docker pulls the right variant automatically. See Versioning & Compatibility.

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

Docker Compose

docker-compose.yml:

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

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

Full list: Environment variables.

Build image from repo

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

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

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


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

Database

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

Wallet

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

Webhooks

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

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

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

import hmac, hashlib

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

Profile

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

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

Minimal setup

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

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

With Docker

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

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

Troubleshooting

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

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.
HTTP
# Add a relay
curl -s -X POST $BASE_URL/relays \
  -H "Authorization: Bearer $AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"relay": "wss://relay.damus.io"}'

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

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

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


Next: SDK

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:

  1. Your application generates an authentication challenge
  2. The challenge is published to Nostr relays
  3. The user's wallet picks up the challenge
  4. The user approves or denies
  5. The response is published back via relays
  6. 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
Speed10+ minutesSub-second
FeesVariable, can be highMinimal (< 1 sat)
PrivacyPublic blockchainOff-chain
MicropaymentsImpracticalNative 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:

  1. Payment request: Your app requests a payment through Portal
  2. Nostr message: Request is sent to the user via Nostr
  3. Wallet notification: User's wallet shows the payment request
  4. User approval: User approves or denies
  5. Lightning payment: Wallet sends payment via Lightning
  6. 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.minor version 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',
});
OptionRequiredDescription
baseUrlYesHTTP base URL of your Portal instance
authTokenYesBearer token matching PORTAL__AUTH__AUTH_TOKEN
autoPollingIntervalMsNoEnable auto-polling; interval in ms (e.g. 500)
webhookSecretNoEnable webhook mode with HMAC-SHA256 verification

API Reference

Auth & Users

MethodDescription
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

MethodDescription
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

MethodDescription
fetchProfile(mainKey)Fetch a user's profile (Profile | null).
setProfile(profile)Set or update a profile.
fetchNip05Profile(nip05)Resolve a NIP-05 identifier.

JWT

MethodDescription
issueJwt(target_key, duration_hours)Issue a JWT for the given key.
verifyJwt(public_key, token)Verify a JWT and return claims.

Cashu & Relays

MethodDescription
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

MethodDescription
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

CodeWhen
NOT_CONNECTEDMethod called before connect() or after disconnect.
CONNECTION_TIMEOUTConnection did not open in time.
CONNECTION_CLOSEDSocket closed unexpectedly.
AUTH_FAILEDInvalid or rejected auth token.
UNEXPECTED_RESPONSEServer sent unexpected response type.
SERVER_ERRORServer returned an error.
PARSE_ERRORFailed to parse a message.

Types

  • Currency — e.g. Currency.Millisats
  • TimestampTimestamp.fromDate(date), Timestamp.fromNow(seconds), toDate(), toJSON()
  • Profileid, pubkey, name, display_name, picture, about, nip05
  • SinglePaymentRequestContent, RecurringPaymentRequestContent, InvoiceRequestContent
  • AuthResponseData, 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.minor version 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 classDescription
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 classDescription
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 classDescription
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 classDescription
IssueJwtRequest(targetKey, durationHours)Issue a JWT. Response: IssueJwtResponse.token()
VerifyJwtRequest(publicKey, token)Verify a JWT. Response: VerifyJwtResponse

Cashu & Relays

Request classDescription
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

TypeDescription
Currencye.g. Currency.MILLISATS
SinglePaymentRequestContent(description, amount, currency, ...)Single payment params
RecurringPaymentRequestContent(..., RecurrenceInfo, expiresAt)Recurring payment params
RecurrenceInfo(..., calendar, ..., firstPaymentDue)Calendar: "weekly", "monthly", etc.
Profile(name, displayName, picture, nip05)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:

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

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

Event polling

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

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

Response:

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

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

Webhooks (alternative to polling)

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

Key endpoints

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

Full schema and request/response types: API Reference.

Examples

Authentication flow

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

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

Single payment

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

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

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

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

Profile lookup

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

API Reference (OpenAPI)

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

If the viewer above doesn't load, open the spec directly:

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:

  1. Check existing issues: GitHub Issues
  2. Search documentation: Use Ctrl+F or search feature
  3. Enable debug logging: Capture detailed logs
  4. Create minimal reproduction: Simplify to smallest failing example
  5. Open an issue: Include:
    • Portal version
    • SDK version
    • Environment (OS, Node version)
    • Complete error messages
    • Steps to reproduce

Back to: Documentation Home

Versioning & Compatibility

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

Version scheme

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

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

Compatibility rule

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

In other words:

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

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

Upgrading

When a new major.minor version is released:

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

Example — upgrading to 0.4.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

ComponentVersion
SDK Daemon (getportal/sdk-daemon)0.4.1
TypeScript SDK (portal-sdk)0.4.1
Java SDK0.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

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

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

  2. Run linting

cargo fmt
cargo clippy
  1. Submit a pull request

Code Style

  • Follow Rust conventions
  • Use cargo fmt before committing
  • Fix cargo clippy warnings
  • Write tests for new features

Documentation

When adding features:

  • Update relevant documentation
  • Add code examples
  • Update the changelog

Questions?

  • Open a GitHub issue
  • Join community discussions
  • Read the existing documentation

Thank you for contributing to Portal!