Skip to main content

Overview

Cashu payment requests (NUT-18) allow you to request ecash payments with optional constraints:
  • Amount - Specific amount or open-ended
  • Mints - Restrict to specific mints or accept any
  • Transport - Delivery method (Nostr DM, HTTP POST, WebSocket)
  • Memo - Description for the payment
Payment requests are encoded as creqA... strings and can be shared via QR codes, NFC, or links.

Protocol Specification

Payment Request Structure

import { decodePaymentRequest, PaymentRequestTransportType } from '@cashu/cashu-ts';

interface PaymentRequest {
  // Optional amount in base unit (sats, cents, etc.)
  amount?: number;
  
  // Currency unit ('sat', 'usd', 'eur', etc.)
  unit?: string;
  
  // Allowed mint URLs (empty = any mint)
  mints?: string[];
  
  // Description/memo
  description?: string;
  
  // Transport methods for payment delivery
  transport: Transport[];
}

interface Transport {
  type: PaymentRequestTransportType;
  target: string;  // Nostr pubkey, HTTP URL, WebSocket URL, etc.
  tags?: string[][];
}

enum PaymentRequestTransportType {
  NOSTR = 'nostr',
  POST = 'post',
  WEBSOCKET = 'ws'
}

Encoding/Decoding

import { 
  decodePaymentRequest, 
  encodePaymentRequest 
} from '@cashu/cashu-ts';

// Decode
const request = decodePaymentRequest('creqA1...');
console.log(request);
// {
//   amount: 100,
//   unit: 'sat',
//   mints: ['https://mint.example.com'],
//   transport: [{ type: 'nostr', target: 'npub1...' }]
// }

// Encode
const encoded = encodePaymentRequest({
  amount: 100,
  unit: 'sat',
  transport: [{
    type: PaymentRequestTransportType.NOSTR,
    target: myPubkey
  }]
});
// Returns: 'creqA1...'

Detecting Payment Requests

The useProcessPaymentString hook handles payment request scanning:
import { useProcessPaymentString } from 'hooks/coco/useProcessPaymentString';
import { decodePaymentRequest, PaymentRequestTransportType } from '@cashu/cashu-ts';

// Check if string is a Nostr payment request
function isNostrPaymentRequest(data: string): boolean {
  const trimmed = data.trim();
  if (!trimmed.startsWith('creqA')) return false;
  
  try {
    const decoded = decodePaymentRequest(trimmed);
    const nostrTransport = decoded.transport?.find(
      t => t.type === PaymentRequestTransportType.NOSTR
    );
    return !!nostrTransport;
  } catch {
    return false;
  }
}

Processing Payment Requests

When a payment request is scanned/pasted, Sovran routes based on available data:

Routing Logic

if (isNostrPaymentRequest(data)) {
  const decoded = decodePaymentRequest(data);
  const hasMints = decoded.mints && decoded.mints.length > 0;
  const hasAmount = decoded.amount !== undefined && decoded.amount > 0;
  
  // Calculate valid mints (balance + allowed mints filter)
  const validMints = getValidMints(decoded.mints, decoded.amount);
  const singleValidMint = validMints.length === 1 ? validMints[0] : null;
  
  // Route based on constraints:
  if (!hasMints && !hasAmount) {
    // No constraints - user picks mint + enters amount
    router.navigate('/(send-flow)/currency', {
      to: 'paymentRequest',
      paymentRequest: data,
      unit: decoded.unit || 'sat'
    });
  } else if (!hasMints && hasAmount) {
    if (singleValidMint) {
      // Only 1 valid mint - go directly to confirmation
      router.navigate('/(send-flow)/sendToken', {
        paymentRequest: data,
        amount: String(decoded.amount),
        selectedMintUrl: singleValidMint.mintUrl
      });
    } else {
      // Multiple valid mints - show mint picker
      router.navigate('/(send-flow)/mintSelect', {
        to: 'paymentRequest',
        paymentRequest: data,
        minAmount: String(decoded.amount),
        unit: decoded.unit || 'sat'
      });
    }
  } else if (hasMints && !hasAmount) {
    // Mints specified but no amount - user enters amount
    router.navigate('/(send-flow)/currency', {
      to: 'paymentRequest',
      paymentRequest: data,
      allowedMints: JSON.stringify(decoded.mints),
      unit: decoded.unit || 'sat'
    });
  } else {
    // Both mints and amount specified
    if (singleValidMint) {
      // Direct to confirmation
      router.navigate('/(send-flow)/sendToken', {
        paymentRequest: data,
        amount: String(decoded.amount),
        selectedMintUrl: singleValidMint.mintUrl
      });
    } else {
      // Show mint picker (filtered by allowed mints)
      router.navigate('/(send-flow)/mintSelect', {
        to: 'paymentRequest',
        paymentRequest: data,
        allowedMints: JSON.stringify(decoded.mints),
        minAmount: String(decoded.amount),
        unit: decoded.unit || 'sat'
      });
    }
  }
}

Valid Mints Calculation

function getValidMints(
  allowedMints: string[] | undefined,
  minAmount: number | undefined
) {
  return trustedMints.filter((mint) => {
    const balance = mintBalances[mint.mintUrl] || 0;
    
    // Must be in allowed list (if specified)
    if (allowedMints && allowedMints.length > 0) {
      if (!allowedMints.includes(mint.mintUrl)) {
        return false;
      }
    }
    
    // Must have sufficient balance (if amount specified)
    if (minAmount !== undefined && minAmount > 0) {
      if (balance < minAmount) {
        return false;
      }
    }
    
    // Must have some balance
    if (balance === 0) {
      return false;
    }
    
    return true;
  });
}

Fulfilling Payment Requests

The SendTokenScreen handles payment request fulfillment:

Payment Request Mode

import { SendTokenScreen } from 'components/screens/SendTokenScreen';

<SendTokenScreen
  paymentRequest={{
    encodedRequest: 'creqA1...',
    amount: 100,
    mintUrl: 'https://mint.example.com'
  }}
  initialNostrSent={false}
  onNavigateBack={() => router.back()}
/>

Workflow

  1. Display confirmation UI
    • Show amount, destination (Nostr npub or URL)
    • Preview memo/description
    • Confirm selected mint
  2. Create ecash token
    import { useSend } from 'coco-cashu-react';
    
    const { send } = useSend();
    
    const token = await send({
      mintUrl: selectedMintUrl,
      amount: paymentRequest.amount,
      unit: paymentRequest.unit || 'sat'
    });
    
  3. Send via transport Nostr DM:
    import { useNostrDirectMessage } from 'hooks/useNostrDirectMessage';
    
    const { sendDirectMessage } = useNostrDirectMessage();
    
    const nostrTransport = decoded.transport.find(
      t => t.type === PaymentRequestTransportType.NOSTR
    );
    
    if (nostrTransport) {
      await sendDirectMessage({
        recipientPubkey: nostrTransport.target,
        message: token,
        encrypted: true
      });
    }
    
    HTTP POST:
    const postTransport = decoded.transport.find(
      t => t.type === PaymentRequestTransportType.POST
    );
    
    if (postTransport) {
      await fetch(postTransport.target, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token })
      });
    }
    
  4. Show confirmation
    • Display success message
    • Update transaction history
    • Return to previous screen

Creating Payment Requests

Basic Example

import { encodePaymentRequest, PaymentRequestTransportType } from '@cashu/cashu-ts';
import { nip19 } from 'nostr-tools';

function createPaymentRequest({
  amount,
  myPubkey,
  allowedMints = []
}: {
  amount?: number;
  myPubkey: string;
  allowedMints?: string[];
}) {
  const npub = nip19.npubEncode(myPubkey);
  
  const request = encodePaymentRequest({
    amount,
    unit: 'sat',
    mints: allowedMints,
    description: 'Payment for services',
    transport: [
      {
        type: PaymentRequestTransportType.NOSTR,
        target: npub,
        tags: []
      }
    ]
  });
  
  return request; // 'creqA1...'
}

QR Code Display

import QRCode from 'react-native-qrcode-svg';

function PaymentRequestQR({ request }: { request: string }) {
  return (
    <View>
      <QRCode 
        value={request} 
        size={300}
        logo={require('./assets/cashu-logo.png')}
      />
      
      <TouchableOpacity onPress={() => {
        Clipboard.setStringAsync(request);
      }}>
        <Text>Copy Payment Request</Text>
      </TouchableOpacity>
    </View>
  );
}

Scan History

Payment requests are saved to scan history:
import { useScanHistoryStore } from 'stores/scanHistoryStore';

const addScan = useScanHistoryStore(state => state.addScan);

// Save payment request scan
addScan(
  rawData,           // Original scanned string
  cleanedData,       // Trimmed/normalized
  'paymentRequest',  // Type
  'qr'              // Source: 'qr' | 'paste' | 'deeplink'
);

Mint Filtering

When payment request specifies allowed mints, filter the mint list:
import { MintListScreen } from 'components/screens/MintListScreen';

const allowedMints = decoded.mints || [];

<MintListScreen
  requireBalance={true}
  minAmount={decoded.amount}
  allowedMints={allowedMints}  // Only show these mints
  onMintSelect={(mint) => {
    // User selected valid mint
  }}
/>
The list screen filters internally:
const filteredMints = trustedMints.filter((mint) => {
  // If allowedMints specified, only include those
  if (allowedMints && allowedMints.length > 0) {
    if (!allowedMints.includes(mint.mintUrl)) {
      return false;
    }
  }
  
  // Must have sufficient balance
  if (minAmount && balance[mint.mintUrl] < minAmount) {
    return false;
  }
  
  return true;
});

Nostr Integration

Sending Payments via DM

Sovran uses NIP-04 encrypted DMs to deliver tokens:
import { useNostrDirectMessage } from 'hooks/useNostrDirectMessage';
import { nip19 } from 'nostr-tools';

const { sendDirectMessage } = useNostrDirectMessage();

// Extract pubkey from payment request
const nostrTransport = decoded.transport.find(
  t => t.type === PaymentRequestTransportType.NOSTR
);

if (nostrTransport) {
  // Target might be npub or hex pubkey
  const recipientPubkey = nostrTransport.target.startsWith('npub')
    ? nip19.decode(nostrTransport.target).data as string
    : nostrTransport.target;
  
  // Send encrypted DM with token
  await sendDirectMessage({
    recipientPubkey,
    message: token,
    encrypted: true
  });
}

Receiving Payment Notifications

Listen for incoming DMs with tokens:
import { useSubscribe } from '@nostr-dev-kit/ndk-mobile';
import { isValidEcashToken } from '@/helper/coco/utils';

const filters = [
  { kinds: [4], '#p': [myPubkey], limit: 50 } // NIP-04 DMs
];

const { events } = useSubscribe({ filters });

useEffect(() => {
  events.forEach((event) => {
    // Decrypt DM
    const decrypted = decryptDM(event, myPrivkey);
    
    // Check if it's a Cashu token
    if (isValidEcashToken(decrypted)) {
      // Show notification
      showTokenReceivedNotification({
        sender: event.pubkey,
        token: decrypted
      });
    }
  });
}, [events]);

Currency Screen Integration

When amount is missing, the currency screen handles it:
import { CurrencyScreen } from 'components/screens/CurrencyScreen';

// Route from payment request scanner
router.navigate('/(send-flow)/currency', {
  to: 'paymentRequest',
  paymentRequest: encodedRequest,
  allowedMints: JSON.stringify(decoded.mints),
  unit: decoded.unit || 'sat'
});

// CurrencyScreen allows user to enter amount
// Then navigates to SendTokenScreen with paymentRequest param

Best Practices

Always check if you have a trusted mint that’s in the allowed list before showing confirmation UI:
const hasValidMint = decoded.mints?.some(url => 
  trustedMints.some(m => m.mintUrl === url)
) ?? true; // true if no restriction

if (!hasValidMint) {
  Alert.alert('No valid mints', 'You don\'t trust any of the allowed mints');
}
If amount is missing, show an input UI rather than rejecting the request. The requester may want any amount.
Display how the payment will be delivered (Nostr DM, HTTP POST, etc.) so users understand where it’s going.
Show a confirmation screen with:
  • Recipient (npub or URL)
  • Amount and unit
  • Selected mint
  • Memo/description

Error Handling

try {
  const decoded = decodePaymentRequest(data);
  
  // Check for Nostr transport
  const nostrTransport = decoded.transport?.find(
    t => t.type === PaymentRequestTransportType.NOSTR
  );
  
  if (!nostrTransport) {
    throw new Error('Only Nostr transport is supported');
  }
  
  // Validate amount
  if (decoded.amount && decoded.amount <= 0) {
    throw new Error('Invalid amount');
  }
  
  // Check mint compatibility
  if (decoded.mints && decoded.mints.length > 0) {
    const validMints = getValidMints(decoded.mints, decoded.amount);
    if (validMints.length === 0) {
      throw new Error('No valid mints available');
    }
  }
  
} catch (err) {
  console.error('Payment request error:', err);
  Alert.alert('Invalid Payment Request', err.message);
}

Mint Management

Manage allowed mints for payment requests

Cashu Overview

Learn about Cashu protocol fundamentals

Build docs developers (and LLMs) love