Learn how to build applications that can serve multiple DRIP communities simultaneously. This guide covers the technical and business aspects of multi-realm app development.

Overview

Multi-realm apps are applications that can be authorized and used by multiple DRIP communities. Unlike realm clients that are tied to a single community, multi-realm apps can scale across the entire DRIP ecosystem.
This guide covers app creation, publishing, realm authorization, scopes, and distribution for DRIP Apps.

App Development Lifecycle

Creating Your App

1. Access the Developer Portal

1

Navigate to Developer Portal

Go to Admin > Developer in your DRIP dashboard
2

Switch to Drip Apps Tab

Click on the Drip Apps tab to manage your applications
3

Submit New App

Click Submit New App to start the creation process

2. App Configuration

You can create an API client and start building/testing your app before publishing. While your app is not published, its API client only works on your own realm. To allow other realms to authorize and use your app, you must publish it. Publishing enables other realms to authorize your app. App Store listing happens after manual verification via email to [email protected].
When creating your app, youโ€™ll need to provide:
{
  "name": "My Awesome App",
  "description": "A comprehensive tool for community engagement",
  "category": "productivity",
  "website": "https://myapp.com",
  "supportUrl": "https://myapp.com/support",
  "privacyPolicy": "https://myapp.com/privacy",
  "termsOfService": "https://myapp.com/terms",
  "logoUrl": "https://myapp.com/logo.png",
  "screenshots": [
    "https://myapp.com/screenshot1.png",
    "https://myapp.com/screenshot2.png"
  ],
  "requestedScopes": [
    "realm:read",
    "members:read",
    "members:write",
    "points:write"
  ]
}

Key Differences from Single-Realm Apps

Single-Realm Apps

  • Fixed realm context
  • Direct API key access
  • Immediate deployment
  • Simple authorization

Multi-Realm Apps

  • Dynamic realm context
  • App client credentials
  • Publishing required for cross-realm authorization
  • Complex authorization flow

Authorization Architecture

Realm-Level Authorization

Publishing your app enables cross-realm authorization. Each realm admin authorizes your app and grants scopes for their realm.

Managing Authorized Realms

class MultiRealmAppClient {
  constructor(appClientSecret) {
    this.appClientSecret = appClientSecret;
    this.authorizedRealms = new Map();
    this.baseUrl = 'https://api.drip.re/api/v1';
  }

  async loadAuthorizedRealms() {
    const response = await fetch(`${this.baseUrl}/apps/:appId/authorized-realms`, {
      headers: {
        'Authorization': `Bearer ${this.appClientSecret}`,
        'Content-Type': 'application/json'
      }
    });

    const data = await response.json();
    
    // Store authorized realms with their permissions
    for (const authorization of data.data) {
      this.authorizedRealms.set(authorization.realmId, {
        realm: authorization.realm,
        approvedScopes: authorization.approvedScopes,
        authorizedAt: authorization.authorizedAt,
        settings: authorization.settings || {}
      });
    }

    return this.authorizedRealms;
  }

  isAuthorizedForRealm(realmId) {
    return this.authorizedRealms.has(realmId);
  }

  hasScope(realmId, scope) {
    const auth = this.authorizedRealms.get(realmId);
    return auth && auth.approvedScopes.includes(scope);
  }

  async makeRealmRequest(realmId, method, endpoint, data = null) {
    if (!this.isAuthorizedForRealm(realmId)) {
      throw new Error(`Not authorized for realm ${realmId}`);
    }

    const url = `${this.baseUrl}${endpoint}`;
    const options = {
      method,
      headers: {
        'Authorization': `Bearer ${this.appClientSecret}`,
        'Content-Type': 'application/json'
      }
    };

    if (data) {
      options.body = JSON.stringify(data);
    }

    const response = await fetch(url, options);
    
    if (!response.ok) {
      const error = await response.json();
      throw new Error(`API Error: ${response.status} - ${error.message}`);
    }

    return response.json();
  }
}

Scopes and Permissions

  • Your app selects requestedScopes.
  • Realms grant those scopes during authorization per realm.
  • Changing requested scopes requires realms to reauthorize.
  • No platform-level scope approval at this time; all scopes are realm-granted.

Data Isolation and Management

Per-Realm Data Storage

Each realmโ€™s data should be properly isolated:
class RealmDataManager {
  constructor(appClient) {
    this.appClient = appClient;
    this.realmData = new Map();
  }

  async initializeRealmData(realmId) {
    if (this.realmData.has(realmId)) {
      return this.realmData.get(realmId);
    }

    // Load realm-specific configuration
    const realmInfo = await this.appClient.makeRealmRequest(
      realmId, 
      'GET', 
      `/realms/${realmId}`
    );

    // Initialize realm data structure
    const realmData = {
      info: realmInfo,
      settings: await this.loadRealmSettings(realmId),
      cache: new Map(),
      lastUpdated: Date.now()
    };

    this.realmData.set(realmId, realmData);
    return realmData;
  }

  async loadRealmSettings(realmId) {
    // Load app-specific settings for this realm
    // This could come from your own database or DRIP's app settings
    return {
      pointsPerAction: 10,
      enableNotifications: true,
      customBranding: {
        primaryColor: '#667eea',
        logo: null
      }
    };
  }

  async updateRealmSettings(realmId, settings) {
    const realmData = await this.initializeRealmData(realmId);
    realmData.settings = { ...realmData.settings, ...settings };
    
    // Persist to your database
    await this.persistRealmSettings(realmId, realmData.settings);
  }

  getRealmData(realmId) {
    return this.realmData.get(realmId);
  }
}

Cross-Realm Analytics

Aggregate data across multiple realms while respecting boundaries:
class CrossRealmAnalytics {
  constructor(appClient) {
    this.appClient = appClient;
  }

  async getAggregatedStats() {
    const authorizedRealms = this.appClient.authorizedRealms;
    const stats = {
      totalRealms: authorizedRealms.size,
      totalMembers: 0,
      totalPoints: 0,
      realmBreakdown: []
    };

    // Process each realm in parallel
    const realmPromises = Array.from(authorizedRealms.keys()).map(async (realmId) => {
      try {
        if (!this.appClient.hasScope(realmId, 'members:read')) {
          return null; // Skip realms without permission
        }

        const members = await this.appClient.makeRealmRequest(
          realmId,
          'GET',
          `/realm/${realmId}/members/search?type=drip-id&values=all`
        );

        const realmStats = {
          realmId,
          realmName: authorizedRealms.get(realmId).realm.name,
          memberCount: members.data.length,
          totalPoints: members.data.reduce((sum, member) => {
            return sum + (member.pointBalances[0]?.balance || 0);
          }, 0)
        };

        stats.totalMembers += realmStats.memberCount;
        stats.totalPoints += realmStats.totalPoints;

        return realmStats;
      } catch (error) {
        console.error(`Error fetching stats for realm ${realmId}:`, error);
        return null;
      }
    });

    const realmResults = await Promise.all(realmPromises);
    stats.realmBreakdown = realmResults.filter(result => result !== null);

    return stats;
  }

  async getTopMembersAcrossRealms(limit = 10) {
    const authorizedRealms = this.appClient.authorizedRealms;
    const allMembers = [];

    for (const [realmId, auth] of authorizedRealms) {
      if (!this.appClient.hasScope(realmId, 'members:read')) {
        continue;
      }

      try {
        const members = await this.appClient.makeRealmRequest(
          realmId,
          'GET',
          `/realm/${realmId}/members/search?type=drip-id&values=all`
        );

        // Add realm context to each member
        for (const member of members.data) {
          allMembers.push({
            ...member,
            realmId,
            realmName: auth.realm.name
          });
        }
      } catch (error) {
        console.error(`Error fetching members for realm ${realmId}:`, error);
      }
    }

    // Sort by points and return top members
    return allMembers
      .filter(member => member.pointBalances && member.pointBalances.length > 0)
      .sort((a, b) => {
        const aPoints = a.pointBalances[0]?.balance || 0;
        const bPoints = b.pointBalances[0]?.balance || 0;
        return bPoints - aPoints;
      })
      .slice(0, limit)
      .map((member, index) => ({
        rank: index + 1,
        name: member.displayName || member.username,
        points: member.pointBalances[0].balance,
        realmName: member.realmName,
        realmId: member.realmId
      }));
  }
}

User Experience Patterns

Realm Selection Interface

Create a smooth realm selection experience:
class RealmSelector {
  constructor(appClient) {
    this.appClient = appClient;
    this.currentRealmId = null;
  }

  async renderRealmSelector() {
    const realms = Array.from(this.appClient.authorizedRealms.values());
    
    return `
      <div class="realm-selector">
        <h3>Select Community</h3>
        <div class="realm-grid">
          ${realms.map(auth => `
            <div class="realm-card" onclick="selectRealm('${auth.realm.id}')">
              <img src="${auth.realm.imageUrl || '/default-realm.png'}" alt="${auth.realm.name}">
              <h4>${auth.realm.name}</h4>
              <p>${auth.realm.memberCount || 0} members</p>
              <div class="scopes">
                ${auth.approvedScopes.slice(0, 3).map(scope => 
                  `<span class="scope-badge">${scope}</span>`
                ).join('')}
              </div>
            </div>
          `).join('')}
        </div>
      </div>
    `;
  }

  selectRealm(realmId) {
    if (!this.appClient.isAuthorizedForRealm(realmId)) {
      throw new Error('Not authorized for this realm');
    }

    this.currentRealmId = realmId;
    this.onRealmChanged(realmId);
  }

  onRealmChanged(realmId) {
    // Override this method to handle realm changes
    console.log(`Switched to realm: ${realmId}`);
  }
}

Context-Aware UI

Adapt your UI based on available permissions:
class ContextAwareUI {
  constructor(appClient, realmId) {
    this.appClient = appClient;
    this.realmId = realmId;
  }

  renderMemberActions() {
    const canRead = this.appClient.hasScope(this.realmId, 'members:read');
    const canWrite = this.appClient.hasScope(this.realmId, 'members:write');
    const canAwardPoints = this.appClient.hasScope(this.realmId, 'points:write');

    if (!canRead) {
      return '<p>No member permissions for this realm</p>';
    }

    return `
      <div class="member-actions">
        ${canRead ? '<button onclick="loadMembers()">View Members</button>' : ''}
        ${canWrite ? '<button onclick="editMember()">Edit Member</button>' : ''}
        ${canAwardPoints ? '<button onclick="awardPoints()">Award Points</button>' : ''}
      </div>
    `;
  }

  renderBasedOnPermissions() {
    const auth = this.appClient.authorizedRealms.get(this.realmId);
    const scopes = auth.approvedScopes;

    const features = {
      analytics: scopes.includes('members:read'),
      pointManagement: scopes.includes('points:write'),
      questManagement: scopes.includes('quests:write'),
      storeManagement: scopes.includes('store:write')
    };

    return `
      <div class="app-features">
        ${features.analytics ? this.renderAnalytics() : ''}
        ${features.pointManagement ? this.renderPointManagement() : ''}
        ${features.questManagement ? this.renderQuestManagement() : ''}
        ${features.storeManagement ? this.renderStoreManagement() : ''}
      </div>
    `;
  }
}

Scaling Considerations

Database Design

Structure your database to handle multiple realms efficiently:
-- Realm-specific tables
CREATE TABLE realm_settings (
  realm_id VARCHAR(24) PRIMARY KEY,
  app_settings JSONB NOT NULL,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE realm_analytics (
  id SERIAL PRIMARY KEY,
  realm_id VARCHAR(24) NOT NULL,
  metric_name VARCHAR(100) NOT NULL,
  metric_value NUMERIC NOT NULL,
  recorded_at TIMESTAMP DEFAULT NOW(),
  
  INDEX idx_realm_metric (realm_id, metric_name),
  INDEX idx_recorded_at (recorded_at)
);

CREATE TABLE cross_realm_data (
  id SERIAL PRIMARY KEY,
  data_type VARCHAR(50) NOT NULL,
  aggregated_value JSONB NOT NULL,
  realm_count INTEGER NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

Caching Strategy

Implement realm-aware caching:
class MultiRealmCache {
  constructor(ttl = 300000) { // 5 minutes
    this.cache = new Map();
    this.ttl = ttl;
  }

  getKey(realmId, type, identifier) {
    return `${realmId}:${type}:${identifier}`;
  }

  set(realmId, type, identifier, data) {
    const key = this.getKey(realmId, type, identifier);
    this.cache.set(key, {
      data,
      timestamp: Date.now()
    });
  }

  get(realmId, type, identifier) {
    const key = this.getKey(realmId, type, identifier);
    const cached = this.cache.get(key);

    if (!cached) {
      return null;
    }

    if (Date.now() - cached.timestamp > this.ttl) {
      this.cache.delete(key);
      return null;
    }

    return cached.data;
  }

  invalidateRealm(realmId) {
    // Remove all cached data for a specific realm
    for (const key of this.cache.keys()) {
      if (key.startsWith(`${realmId}:`)) {
        this.cache.delete(key);
      }
    }
  }

  clear() {
    this.cache.clear();
  }
}

Business Model Considerations

Pricing Strategies

Different approaches for multi-realm apps:

Revenue Tracking

Track revenue per realm for better insights:
class RevenueTracker {
  constructor() {
    this.realmRevenue = new Map();
  }

  recordRealmRevenue(realmId, amount, period) {
    if (!this.realmRevenue.has(realmId)) {
      this.realmRevenue.set(realmId, {
        totalRevenue: 0,
        monthlyRevenue: [],
        firstPayment: Date.now()
      });
    }

    const realmData = this.realmRevenue.get(realmId);
    realmData.totalRevenue += amount;
    realmData.monthlyRevenue.push({
      period,
      amount,
      timestamp: Date.now()
    });
  }

  getRealmMetrics(realmId) {
    const data = this.realmRevenue.get(realmId);
    if (!data) return null;

    const monthlyAverage = data.monthlyRevenue.reduce((sum, payment) => 
      sum + payment.amount, 0) / data.monthlyRevenue.length;

    return {
      totalRevenue: data.totalRevenue,
      monthlyAverage,
      paymentCount: data.monthlyRevenue.length,
      customerLifetime: Date.now() - data.firstPayment
    };
  }
}

Testing Multi-Realm Apps

Testing Strategy

class MultiRealmTester {
  constructor() {
    this.testRealms = [
      { id: 'test-realm-1', name: 'Test Realm 1', scopes: ['members:read', 'points:write'] },
      { id: 'test-realm-2', name: 'Test Realm 2', scopes: ['members:read'] },
      { id: 'test-realm-3', name: 'Test Realm 3', scopes: ['members:read', 'members:write', 'points:write'] }
    ];
  }

  async testCrossRealmFunctionality() {
    const appClient = new MultiRealmAppClient(process.env.TEST_APP_CLIENT_SECRET);
    
    // Mock authorized realms for testing
    for (const testRealm of this.testRealms) {
      appClient.authorizedRealms.set(testRealm.id, {
        realm: testRealm,
        approvedScopes: testRealm.scopes,
        authorizedAt: new Date().toISOString()
      });
    }

    // Test realm-specific operations
    for (const testRealm of this.testRealms) {
      console.log(`Testing realm: ${testRealm.name}`);
      
      // Test permission checking
      assert(appClient.hasScope(testRealm.id, 'members:read'));
      
      if (appClient.hasScope(testRealm.id, 'points:write')) {
        // Test point operations
        console.log('โœ… Can award points in this realm');
      } else {
        console.log('โŒ Cannot award points in this realm');
      }
    }

    // Test cross-realm analytics
    const analytics = new CrossRealmAnalytics(appClient);
    const stats = await analytics.getAggregatedStats();
    
    assert(stats.totalRealms === this.testRealms.length);
    console.log('โœ… Cross-realm analytics working');
  }
}

Deployment and Monitoring

Health Checks for Multi-Realm Apps

async function multiRealmHealthCheck() {
  const appClient = new MultiRealmAppClient(process.env.APP_CLIENT_SECRET);
  
  try {
    await appClient.loadAuthorizedRealms();
    
    const healthStatus = {
      status: 'healthy',
      timestamp: new Date().toISOString(),
      authorizedRealms: appClient.authorizedRealms.size,
      realmStatus: {}
    };

    // Test a sample of realms
    const realmIds = Array.from(appClient.authorizedRealms.keys()).slice(0, 3);
    
    for (const realmId of realmIds) {
      try {
        await appClient.makeRealmRequest(realmId, 'GET', `/realms/${realmId}`);
        healthStatus.realmStatus[realmId] = 'healthy';
      } catch (error) {
        healthStatus.realmStatus[realmId] = 'unhealthy';
        healthStatus.status = 'degraded';
      }
    }

    return healthStatus;
  } catch (error) {
    return {
      status: 'unhealthy',
      timestamp: new Date().toISOString(),
      error: error.message
    };
  }
}
Building something cool? Share your multi-realm app in our Discord community and get feedback from other developers! ๐Ÿš€

Creating Secure Installation URLs

For advanced app distribution, you can create encrypted installation URLs that include platform-specific data and expiration times.

Prerequisites

Encryption Key

You will need the encryption key provided for your app. This key is unique and ensures secure encryption.

Development Environment

Use a JavaScript environment (Node.js or browser console) to run the encryption logic.

Encryption Implementation

// Import the CryptoJS library
const CryptoJS = require("crypto-js");

// Encryption function
function encryptData(data, passphrase) {
  const json = JSON.stringify(data);
  return CryptoJS.AES.encrypt(json, passphrase).toString();
}

// Example Usage:
const encryptionKey = "your-app-encryption-key"; // Replace with your app's key
const platformData = {
  platformType: "slack",       // Replace with your platform type
  platformId: "MY_TEAM_ID",    // Replace with your platform ID
  expirationTime: Date.now() + 30 * 60 * 1000 // Optional: expires in 30 minutes
};

// Encrypt the data
const encrypted = encryptData(platformData, encryptionKey);
console.log("Encrypted Data:", encrypted);

// Build the URL
const BASE_URL = "https://app.drip.re/admin/apps/authorize";
const appId = "your-app-id"; // Replace with your app's ID

// Final URL
const installUrl = `${BASE_URL}?appId=${appId}&epfo=${encodeURIComponent(encrypted)}`;
console.log("Installation URL:", installUrl);

URL Parameters Explained

Example Output

When you run the encryption code, youโ€™ll get:
// Encrypted Data Example:
"U2FsdGVkX1+L+8EzH4IbNHRgXLSOh9OUCzPZ8tjVdNA1M5..."

// Generated Installation URL:
"https://app.drip.re/admin/apps/authorize?appId=your-app-id&epfo=U2FsdGVkX1%2BL%2B8EzH4IbNHRgXLSOh9OUCzPZ8tjVdNA1M5..."

Security Best Practices

Keep Keys Secure

  • Never share your encryption key publicly
  • Store keys in environment variables
  • Rotate keys periodically
  • Use different keys for different environments

URL Expiration

  • Always set expiration times for sensitive URLs
  • Use shorter expiration times for production
  • Monitor for expired URL usage attempts
  • Provide clear error messages for expired URLs

Verify Integration

  • Test encrypted URLs before sharing
  • Verify decryption works correctly
  • Test with different platform types
  • Monitor installation success rates

Error Handling

  • Handle decryption failures gracefully
  • Provide fallback installation methods
  • Log encryption/decryption errors
  • Validate platform data before encryption

Verifying Authorization Programmatically

Each realm admin must explicitly authorize your app. You can check authorization and approved scopes via the API.
async function checkRealmAuthorization(realmId, appId, appClientSecret) {
  const response = await fetch(`https://api.drip.re/api/v1/apps/${appId}/authorized-realms`, {
    headers: {
      'Authorization': `Bearer ${appClientSecret}`,
      'Content-Type': 'application/json'
    }
  });
  
  const authorizations = await response.json();
  return authorizations.data.find(auth => auth.realmId === realmId);
}

// Usage
const authorization = await checkRealmAuthorization('REALM_ID', 'YOUR_APP_ID', 'YOUR_APP_CLIENT_SECRET');
if (authorization) {
  console.log('Authorized scopes:', authorization.approvedScopes);
} else {
  console.log('App not authorized for this realm');
}

Next Steps