VASP Quick Start Guide

This guide will walk you through adding UMA Auth to an existing UMA VASP implementation. To implement the base UMA standard, see the UMA Standard guide.
Implementation of UMA Auth requires 5 simple steps:
  1. Implement the UMA Auth OpenAPI Schema
  2. Configure and run the UMA NWC Docker image
  3. Implement the token exchange process with the Docker image
  4. Update your uma-configuration file
  5. Test your implementation
The UMA Auth OpenAPI Schema is a RESTful API that the Nostr Wallet Connect (NWC) image uses to communicate with the main VASP server. You can see the full schema file here. This same github repository also contains generated model libraries for Python, Go, Kotlin/Java, and TypeScript. You can also generate your own libraries for whatever framework or language you're using from the schema itself. See the VASP OpenAPI Schema guide for more details.
You'll need to implement the specified API endpoints in your VASP server to handle the NWC image requests. Technically, you do not need to support all of the endpoints in the schema, but the more you support, the more functionality you can provide to your users. At a minimum, your existing UMA VASP implementation will give you a simple starting point for implementing:
  • GET /receiver/lud16/{receiver_address}: This is equivalent to the lnurlp request in UMA.
  • GET /quote/lud16: This is equivalent to the payreq request in UMA.
  • POST /quote/{payment_hash}: This actually pays a quote that was previously generated.
  • POST /payments/lud16: This is all 3 of the above combined into one request.
Here's a simple example of what the GET /receiver/lud16/{receiver_address} endpoint might look like in Python:
from flask import Flask, request, jsonify
# If importing the generated models libary (pip install uma_auth), you would import them like this:
from uma_auth.models.lookup_user_response import LookupUserResponse

app = Flask(__name__)

@app.route('/receiver/lud16/<receiver_address>', methods=['GET'])
def lookup_user(receiver_address):
    # Assuming you have a `sending_vasp` object that can handles the UMA lnurlp lookup:
    lookup_response = sending_vasp.handle_uma_lookup(receiver_uma)
    currencies = lookup_response.get("receiverCurrencies") or []
    return LookupUserResponse(
        currencies=[
            CurrencyPreference(
                currency=Currency(
                  code=currency.get("code"),
                  symbol=currency.get("symbol"),
                  decimals=currency.get("decimals"),
                  name=currency.get("name"),
                ),
                multiplier=currency.get("multiplier"),
                min=(
                    currency.get("convertible").get("min")
                    if "convertible" in currency
                    else currency.get("minSendable")
                ),
                max=(
                    currency.get("convertible").get("max")
                    if "convertible" in currency
                    else currency.get("maxSendable")
                ),
            )
            for currency in currencies
        ]
    ).to_dict()
The other endpoints above can similarly be implemented simply by mapping your existing UMA VASP functionality to the OpenAPI schema data types.
You also must support the following endpoints for the NWC image to work:
  • GET /info: Get information about the user's wallet connection.
  • GET /budget_estimate: Used to get a cost estimate in the user's default currency if they are making a payment in a different currency.
The remaining endpoints should also be implemented to provide a full NWC experience. They include things like creating a bolt11 invoice, paying a bolt11 invoice, getting the user's balance, listing transactions and invoices, etc. Make sure that you appropriately set your supported NWC commands in the NWC Docker image configuration (see below).
Next, we'll need to configure and run the UMA NWC Docker image. This image handles all of the Nostr Wallet Connect communication, budget management, connection setup and management UI, etc. It is configurable via environment variables to control UI branding, database storage, supported NWC commands, and more. You can find the code for the image in this github repo along with steps for running it from source. See the UMA NWC Docker Image guide for full details. For now, let's just get the image up and running locally!
You can just pull the latest version from the github container registry:
docker pull ghcr.io/uma-universal-money-address/uma-nwc-server:latest
Next, we're going to need to configure the image to correctly point to your VASP server, DB, secrets, etc. Here's an example of a local.py configuration file that you can use to run the image locally. It assumes you also have your UMA VASP server running locally on port 5001.
import os
import secrets

# This will create a local sqlite database in instance/nwc.sqlite
DATABASE_URI: str = "sqlite+aiosqlite:///" + os.path.join(
    os.getcwd(), "instance", "nwc.sqlite"
)
# A secret key for encrypting cookies
SECRET_KEY: str = secrets.token_hex(32)

# The public key for verifying JWTs from the VASP. See the next section for how to generate this.
UMA_VASP_JWT_PUBKEY = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEVs/o5+uQbTjL3chynL4wXgUg2R9\nq9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg==\n-----END PUBLIC KEY-----"

# The URL to the VASP's login page. This is where the NWC image will redirect users to login.
UMA_VASP_LOGIN_URL = "http://localhost:5001/auth/nwclogin"

# The URL to the VASP's token exchange endpoint. This is where the NWC image will exchange the user's
# short-lived JWT for a long-lived one. See "VASP Token Exchange".
UMA_VASP_TOKEN_EXCHANGE_URL = "http://localhost:5001/umanwc/token"

# The base URL of the VASP's API where you set up the OpenAPI schema in the previous step.
# This is where the NWC image will send requests to the VASP.
VASP_UMA_API_BASE_URL = "http://localhost:5001/umanwc"

# A name for the VASP that will be displayed in the Permission and Connection Management UI.
VASP_NAME = "Pink Drink NWC"

# Replace with your own constant private key via `openssl rand -hex 32`. Save it somewhere safe!
NOSTR_PRIVKEY: str = secrets.token_hex(32)

# The nostr relay where your NWC image will send and receive messages. For development, you can use Alby's public relay.
# See the "Running a Relay" guide for more details.
RELAY = "wss://relay.getalby.com/v1"

# The URL of the NWC image's root domain.
NWC_APP_ROOT_URL = "http://localhost:8080"

# The NWC commands that your VASP supports. This should be a list of strings from the list below that
# corresond to the endpoints you implemented in the OpenAPI schema.
VASP_SUPPORTED_COMMANDS = [
    "pay_invoice",
    "make_invoice",
    "lookup_invoice",
    "get_balance",
    "get_budget",
    "get_info",
    "list_transactions",
    "pay_keysend",
    "lookup_user",
    "fetch_quote",
    "execute_quote",
    "pay_to_address",
]
Then you can run the image locally with the following command:
docker run -p 8080:8081 \
-e QUART_CONFIG=local.py \
ghcr.io/uma-universal-money-address/uma-nwc-server:latest
It won't load quite yet, but we're only a 2 steps away from that!
The NWC image doesn't have its own user database or authentication system. Instead, it relies on the VASP to handle user authentication and provide a JWT that the NWC image can use to identify the user. You can see the NWC Container Auth Token Exchange guide for more details on how this process works. To jump right into a working solution, you'll need to implement 2 steps on your VASP server:
When the user loads a page in the NWC image frontend for the first time, they will be redirected to your VASP's login page that you configured above in the UMA_VASP_LOGIN_URL variable. This should generally use your existing login flow to authenticate the user. Once the user is authenticated (or if they were already logged in), you should generate a short-lived JWT and redirect back to the NWC image with the JWT in the URL. For example:
  1. The user loads the NWC image and is redirected to your login page at http://localhost:5001/auth/nwclogin?redirect_uri=https://localhost:8080/apps/new.
  2. If the user is already logged in, skip this step, otherwise, the user logs in.
  3. After login, generate a short-lived JWT in your backend with the user's ID and any other necessary information. For example:
import jwt

# First double-check that the redirect_uri is one that you know comes from the NWC image.
if not redirect_uri.startswith("http://localhost:8080"):
    return "Invalid redirect_uri", 400

user_nwc_jwt = jwt.encode(
    {
        "sub": user_id,
        # Your vasp domain is the audience and issuer of this token.
        "aud": "localhost:5001",
        "iss": "localhost:5001",
        # Let this token only last 10 minutes.
        "exp": datetime.timestamp(datetime.now() + timedelta(minutes=10)),
        # The NWC image will use this to show the user's UMA in the UI.
        "address": user.get_uma_address(),
    },
    # Store this in secrets!
    jwt_private_key,
    algorithm="ES256",
)
jwt_private_key should be a valid ES256 private key that you can generate with the help of jwt.io or a similar tool. You should store this key securely and not expose it in your code. You also need to configure the public key in the NWC image configuration in the UMA_VASP_JWT_PUBKEY variable to verify this JWT (see above).
  1. Redirect back to the NWC image with the JWT in the URL as a token query param, and the user's JSON-encoded default currency as another param. For example:
https://localhost:8080/apps/new?token=eyJhb...1X2&amp;currency={&quot;code&quot;:&quot;USD&quot;,&quot;symbol&quot;:&quot;$&quot;,&quot;decimals&quot;:2,&quot;name&quot;:&quot;US Dollar&quot;}
When the NWC image receives the short-lived JWT, it will show the user the OAuth/NWC permission page. When the user approves, it will make a POST request to your VASP's token exchange endpoint that you configured above in the UMA_VASP_TOKEN_EXCHANGE_URL variable. This endpoint should verify the short-lived JWT, and if it's still valid, return a long-lived JWT that the NWC image can use as the bearer token for requests to your VASPs UMA Auth API. For example:
import jwt

def user_id_from_jwt(request):
    auth_header = request.headers.get("Authorization")
    if not auth_header:
        abort_with_error(401, "Unauthorized")
    jwt_token = auth_header.split("Bearer ")[-1]
    jwt_public_key = current_app.config.get("NWC_JWT_PUBKEY")
    if not jwt_public_key:
        abort_with_error(500, "JWT public key not configured")
    try:
        decoded = jwt.decode(
            jwt_token,
            key=jwt_public_key,
            algorithms=["ES256"],
            issuer="localhost:5001",
            audience="localhost:5001",
        )
        user_id = decoded.get("sub")
        return user_id
    except jwt.exceptions.InvalidTokenError as e:
        print("Invalid token error", e)
        abort_with_error(401, "Unauthorized")

@app.route("/token", methods=["POST"])
def handle_token_exchange():
    user_id = user_id_from_jwt(request)
    user = User.from_id(user_id)
    if user is None:
        return jsonify({"error": f"User {user_id} not found."}), 404
    body = request.get_json()
    requested_permissions = body.get("permissions")
    if not requested_permissions:
        return jsonify({"error": "Permissions are required."}), 400
    requested_expiration = body.get("expiration")

    jwt_private_key = current_app.config.get("NWC_JWT_PRIVKEY")
    if not jwt_private_key:
        return jsonify({"error": "JWT private key not set in config."}), 500

    claims = {
        "sub": str(user_id),
        "aud": "localhost:5001",
        "iss": "localhost:5001",
        "address": user.get_uma_address(),
    }

    # TODO: Consider saving permissions or adding them to claims in some condensed form.
    if requested_expiration:
        claims["exp"] = requested_expiration

    user_nwc_jwt = jwt.encode(
        claims,
        jwt_private_key,
        algorithm="ES256",
    )
    return jsonify({"token": user_nwc_jwt})
Make sure that in your VASP server UMA Auth API implementation, you verify the long-lived JWT and use the user's ID from the JWT to make requests on their behalf.
@app.route("/payments/lud16", methods=["POST"])
def make_payment():
    user_id = user_id_from_jwt(request) # this might raise a 401 if the JWT is invalid
    user = User.from_id(user_id)
    if user is None:
        return jsonify({"error": f"User {user_id} not found."}), 404
    # ... make the payment
We need to update the uma-configuration discovery document to point to the NWC image where needed. See UMAD-10 for a full spec on this file. You'll need to serve a JSON response at /.well-known/uma-configuration on your VASP server domain which contains at least the following fields:
{
  "name": "Cool VASP",
  "uma_major_versions": [0, 1],

  "authorization_endpoint": "http://localhost:8080/oauth/auth",
  "token_endpoint": "http://localhost:8080/oauth/token",
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"],
  "connection_management_endpoint": "http://localhost:8080/connection/<connection_id>",
  "revocation_endpoint": "http://localhost:8080/oauth/revoke"
}
In production, you'll want to replace localhost:8080 with the actual domain and port where your NWC image is running. These configuration settings help client applications know how to complete the OAuth flow to establish and manage connections.
Now you're all set up! You can test your implementation by trying to connect to this demo client application. Since your vasp is running locally on port 5001, you can use localhost:5001 as the VASP domain in the client app. For example, you can try logging in as $bob@localhost:5001. Play with different methods in the UI to make sure they work as expected.
Now you're all done! You have a fully functioning UMA Auth implementation for your VASP. Just update your configurations and secrets for production, and you're ready to go. Congratulations! 🎉
You can find deeper details on each of these steps in the following guides:
As well as some details about running a relay for your VASP.