Journey Docs
Members APITransfers

Lookup Member

Lookup member before transferring points

POST/members/me/transfers/lookup

Overview

Lookup a member by email, phone, or member code before transferring points. Returns minimal public information to protect member privacy while allowing senders to verify they have the correct recipient.

Authentication

Requires a valid Clerk JWT token in the Authorization header.

Rate Limiting

Rate Limited: 25 requests per hour per member to prevent abuse and protect member privacy.

Request Body

querybodystringrequired

Search query - email address, phone number, or member code

typebodystringrequired

Type of lookup: "email", "phone", or "memberCode"

Request

curl -X POST "http://localhost:3000/v1/members/me/transfers/lookup" \
  -H "Authorization: Bearer YOUR_CLERK_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "friend@example.com",
    "type": "email"
  }'

Response

foundbooleanrequired

Whether a member was found matching the query

memberobject

Member information (only present if found is true)

member object
displayNamestringrequired

Member's display name for verification

avatarUrlstring

Member's avatar URL (null if no avatar)

externalIdstringrequired

Member's external ID (ULID) for use in transfer creation

Response

{
  "found": true,
  "member": {
    "displayName": "John Smith",
    "avatarUrl": "https://cdn.example.com/avatars/john-smith.jpg",
    "externalId": "01ARZ3NDEKTSV4RRFFQ69G5FAV"
  }
}

Lookup Types

Email Lookup

  • Query Format: Valid email address
  • Example: friend@example.com
  • Case Sensitivity: Case insensitive
  • Privacy: Only finds verified email addresses

Phone Lookup

  • Query Format: E.164 format phone number
  • Example: +1234567890
  • Formatting: Country code required
  • Privacy: Only finds verified phone numbers

Member Code Lookup

  • Query Format: Member's unique code
  • Example: MEMBER123 or custom format
  • Case Sensitivity: Case insensitive
  • Usage: Public member codes for easy sharing

Privacy Protection

Limited Information

  • Display Name: Only public display name shown
  • Avatar: Public avatar URL (if set)
  • No Personal Data: Email, phone, addresses not exposed
  • External ID: Safe identifier for transfers

Rate Limiting

  • Limit: 25 requests per hour per member
  • Purpose: Prevent mass harvesting of member information
  • Reset: Rolling 1-hour window
  • Bypass: Not available (security measure)

Self-Lookup Prevention

  • Blocked: Cannot lookup your own account
  • Error: Returns specific error message
  • Purpose: Prevent self-transfer attempts

Use Cases

Transfer Confirmation UI

const confirmTransferRecipient = async (email: string) => {
  try {
    const response = await fetch('/api/members/me/transfers/lookup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        query: email,
        type: 'email'
      })
    });
    
    const result = await response.json();
    
    if (result.found) {
      // Show confirmation dialog
      const confirmed = confirm(
        `Send points to ${result.member.displayName}?`
      );
      
      if (confirmed) {
        // Proceed with transfer using result.member.externalId
        return result.member.externalId;
      }
    } else {
      // Offer to send as gift transfer
      const sendAsGift = confirm(
        'Member not found. Send as gift transfer instead?'
      );
      
      if (sendAsGift) {
        return null; // Use email in gift transfer
      }
    }
  } catch (error) {
    console.error('Lookup failed:', error);
  }
};
const searchMembers = async (query: string, type: 'email' | 'phone' | 'memberCode') => {
  // Debounce to respect rate limits
  if (query.length < 3) return [];
  
  try {
    const response = await fetch('/api/members/me/transfers/lookup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, type })
    });
    
    const result = await response.json();
    
    if (result.found) {
      return [{
        id: result.member.externalId,
        name: result.member.displayName,
        avatar: result.member.avatarUrl,
        type: 'member'
      }];
    }
    
    // Suggest gift transfer option
    return [{
      id: null,
      name: `Send as gift to ${query}`,
      avatar: null,
      type: 'gift'
    }];
  } catch (error) {
    return [];
  }
};

Batch Lookup (Rate-Limited)

const lookupMultipleMembers = async (contacts: Array<{query: string, type: string}>) => {
  const results = [];
  const DELAY_MS = 150; // Respect rate limits
  
  for (const contact of contacts) {
    try {
      const response = await fetch('/api/members/me/transfers/lookup', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(contact)
      });
      
      const result = await response.json();
      results.push({ ...contact, ...result });
      
      // Delay between requests
      await new Promise(resolve => setTimeout(resolve, DELAY_MS));
    } catch (error) {
      results.push({ ...contact, found: false, error: true });
    }
  }
  
  return results;
};

Error Handling

Rate Limit Management

const lookupWithRetry = async (query: string, type: string, maxRetries = 2) => {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch('/api/members/me/transfers/lookup', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query, type })
      });
      
      if (response.status === 429) {
        // Rate limited - wait and retry
        if (attempt < maxRetries) {
          const waitTime = Math.pow(2, attempt) * 1000; // Exponential backoff
          await new Promise(resolve => setTimeout(resolve, waitTime));
          continue;
        }
        throw new Error('Rate limit exceeded');
      }
      
      return response.json();
    } catch (error) {
      if (attempt === maxRetries) throw error;
    }
  }
};

Validation

Query Format Validation

const validateLookupQuery = (query: string, type: string) => {
  const errors = [];
  
  if (!query || query.trim().length === 0) {
    errors.push('Query is required');
  }
  
  if (query.length > 255) {
    errors.push('Query too long (max 255 characters)');
  }
  
  switch (type) {
    case 'email':
      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query)) {
        errors.push('Invalid email format');
      }
      break;
      
    case 'phone':
      if (!/^\+?[1-9]\d{1,14}$/.test(query)) {
        errors.push('Invalid phone format (use E.164)');
      }
      break;
      
    case 'memberCode':
      if (!/^[A-Z0-9]{6,20}$/i.test(query)) {
        errors.push('Invalid member code format');
      }
      break;
      
    default:
      errors.push('Invalid lookup type');
  }
  
  return errors;
};

On this page